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, MouseButton, MouseMsg, Program,
24    WindowSizeMsg, batch, quit, sequence,
25};
26use chrono::Utc;
27use crossterm::{cursor, terminal};
28use futures::future::BoxFuture;
29use glamour::StyleConfig as GlamourStyleConfig;
30use glob::Pattern;
31use serde_json::{Value, json};
32
33use std::collections::{HashMap, VecDeque};
34use std::fmt::Write as _;
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use std::sync::Mutex as StdMutex;
38use std::sync::atomic::{AtomicBool, Ordering};
39
40use crate::agent::{AbortHandle, Agent, AgentEvent, QueueMode};
41use crate::autocomplete::{AutocompleteCatalog, AutocompleteItem, AutocompleteItemKind};
42use crate::config::{Config, ExtensionPolicyConfig, SettingsScope, parse_queue_mode_or_default};
43use crate::extension_events::{InputEventOutcome, apply_input_event_response};
44use crate::extensions::{
45    EXTENSION_EVENT_TIMEOUT_MS, ExtensionDeliverAs, ExtensionEventName, ExtensionHostActions,
46    ExtensionManager, ExtensionSendMessage, ExtensionSendUserMessage, ExtensionSession,
47    ExtensionUiRequest, ExtensionUiResponse,
48};
49use crate::keybindings::{AppAction, KeyBinding, KeyBindings};
50use crate::model::{
51    AssistantMessageEvent, ContentBlock, CustomMessage, ImageContent, Message as ModelMessage,
52    StopReason, TextContent, ThinkingLevel, Usage, UserContent, UserMessage,
53};
54use crate::models::{ModelEntry, ModelRegistry, default_models_path};
55use crate::package_manager::PackageManager;
56use crate::platform::VERSION;
57use crate::providers;
58use crate::resources::{DiagnosticKind, ResourceCliOptions, ResourceDiagnostic, ResourceLoader};
59use crate::session::{Session, SessionEntry, SessionMessage, bash_execution_to_text};
60use crate::theme::{Theme, TuiStyles};
61use crate::tools::{process_file_arguments, resolve_read_path};
62
63#[cfg(all(feature = "clipboard", feature = "image-resize"))]
64use arboard::Clipboard as ArboardClipboard;
65
66mod agent;
67mod commands;
68mod conversation;
69mod ext_session;
70mod file_refs;
71mod keybindings;
72mod model_selector_ui;
73mod perf;
74mod share;
75mod state;
76mod text_utils;
77mod tool_render;
78mod tree;
79mod tree_ui;
80mod view;
81
82use self::agent::{build_user_message, extension_commands_for_catalog};
83pub use self::commands::{
84    SlashCommand, model_entry_matches, parse_scoped_model_patterns, resolve_scoped_model_entries,
85    strip_thinking_level_suffix,
86};
87use self::commands::{
88    format_startup_oauth_hint, parse_bash_command, parse_extension_command,
89    should_show_startup_oauth_hint,
90};
91use self::conversation::conversation_from_session;
92use self::ext_session::{InteractiveExtensionHostActions, InteractiveExtensionSession};
93pub use self::ext_session::{format_extension_ui_prompt, parse_extension_ui_response};
94use self::file_refs::{
95    file_url_to_path, format_file_ref, is_file_ref_boundary, next_non_whitespace_token,
96    parse_quoted_file_ref, path_for_display, split_trailing_punct, strip_wrapping_quotes,
97    unescape_dragged_path,
98};
99use self::perf::{
100    CRITICAL_KEEP_MESSAGES, FrameTimingStats, MemoryLevel, MemoryMonitor, MessageRenderCache,
101    RenderBuffers, micros_as_u64,
102};
103pub use self::state::{AgentState, InputMode, PendingInput};
104use self::state::{
105    AutocompleteState, BranchPickerOverlay, CapabilityAction, CapabilityPromptOverlay,
106    ExtensionCustomOverlay, HistoryList, InjectedMessageQueue, InteractiveMessageQueue,
107    PendingLoginKind, PendingOAuth, QueuedMessageKind, SessionPickerOverlay, SettingsUiEntry,
108    SettingsUiState, TOOL_COLLAPSE_PREVIEW_LINES, ThemePickerItem, ThemePickerOverlay,
109    ToolProgress, format_count,
110};
111pub use self::state::{ConversationMessage, MessageRole};
112use self::text_utils::{queued_message_preview, truncate};
113use self::tool_render::{format_tool_output, render_tool_message};
114use self::tree::{
115    PendingTreeNavigation, TreeCustomPromptState, TreeSelectorState, TreeSummaryChoice,
116    TreeSummaryPromptState, TreeUiState, collect_tree_branch_entries,
117    resolve_tree_selector_initial_id, view_tree_ui,
118};
119
120// ============================================================================
121// Tmux wheel scroll guard
122// ============================================================================
123
124/// RAII guard that overrides tmux WheelUp/WheelDown bindings for the current
125/// pane so that mouse wheel events are forwarded to the application instead of
126/// triggering tmux copy-mode.  When dropped (including on panic), the original
127/// bindings are restored.
128///
129/// The override is pane-scoped: other panes in the same tmux session are not
130/// affected.  If `PI_TMUX_WHEEL_OVERRIDE=0` is set, no override is installed.
131struct TmuxWheelGuard {
132    /// Original WheelUp binding (None if there was no binding).
133    saved_wheel_up: Option<String>,
134    /// Original WheelDown binding (None if there was no binding).
135    saved_wheel_down: Option<String>,
136}
137
138impl TmuxWheelGuard {
139    /// Attempt to install pane-scoped tmux wheel overrides.
140    ///
141    /// Returns `None` if:
142    /// - Not running inside tmux (`$TMUX` unset)
143    /// - `PI_TMUX_WHEEL_OVERRIDE=0` env is set
144    /// - `tmux` binary is not available or returns errors
145    fn install() -> Option<Self> {
146        // Respect opt-out env var.
147        if std::env::var("PI_TMUX_WHEEL_OVERRIDE")
148            .ok()
149            .is_some_and(|v| v == "0")
150        {
151            return None;
152        }
153
154        // Check if we're in tmux.
155        std::env::var_os("TMUX")?;
156
157        // Get the current pane ID.
158        let pane = std::process::Command::new("tmux")
159            .args(["display-message", "-p", "#{pane_id}"])
160            .output()
161            .ok()
162            .and_then(|o| {
163                if o.status.success() {
164                    String::from_utf8(o.stdout)
165                        .ok()
166                        .map(|s| s.trim().to_string())
167                } else {
168                    None
169                }
170            })?;
171
172        if pane.is_empty() {
173            return None;
174        }
175
176        // Save existing WheelUpPane/WheelDownPane bindings so we can restore them.
177        let saved_wheel_up = Self::get_binding("WheelUpPane");
178        let saved_wheel_down = Self::get_binding("WheelDownPane");
179
180        // `bind-key -T root` is global, so make the binding conditional on the
181        // current pane and delegate to the original command for all other panes.
182        Self::install_binding_for_pane(&pane, "WheelUpPane", saved_wheel_up.as_deref());
183        Self::install_binding_for_pane(&pane, "WheelDownPane", saved_wheel_down.as_deref());
184
185        Some(Self {
186            saved_wheel_up,
187            saved_wheel_down,
188        })
189    }
190
191    /// Query the current tmux binding for a key in the root table.
192    fn get_binding(key: &str) -> Option<String> {
193        let output = std::process::Command::new("tmux")
194            .args(["list-keys", "-T", "root"])
195            .output()
196            .ok()?;
197        if !output.status.success() {
198            return None;
199        }
200        let stdout = String::from_utf8_lossy(&output.stdout);
201        // Each line looks like: bind-key    -T root    WheelUpPane    if-shell -F ...
202        for line in stdout.lines() {
203            if Self::binding_key_and_command(line).is_some_and(|(bound_key, _)| bound_key == key) {
204                return Some(line.trim().to_string());
205            }
206        }
207        None
208    }
209
210    /// Extract the bound command payload from a `list-keys` line.
211    fn binding_command(saved_line: &str, key_name: &str) -> Option<String> {
212        let (bound_key, command) = Self::binding_key_and_command(saved_line)?;
213        (bound_key == key_name && !command.is_empty()).then(|| command.to_string())
214    }
215
216    fn binding_key_and_command(saved_line: &str) -> Option<(&str, &str)> {
217        let (_, bind_end) = Self::next_shell_token_bounds(saved_line, 0)?;
218        if saved_line.get(..bind_end)? != "bind-key" {
219            return None;
220        }
221
222        let mut cursor = bind_end;
223        loop {
224            let (token_start, token_end) = Self::next_shell_token_bounds(saved_line, cursor)?;
225            let token = saved_line.get(token_start..token_end)?;
226            cursor = token_end;
227
228            match token {
229                "-T" | "-N" => {
230                    let (_, value_end) = Self::next_shell_token_bounds(saved_line, cursor)?;
231                    cursor = value_end;
232                }
233                _ if token.starts_with('-') => {}
234                _ => {
235                    let command = saved_line.get(cursor..)?.trim_start();
236                    return Some((token, command));
237                }
238            }
239        }
240    }
241
242    fn next_shell_token_bounds(input: &str, from: usize) -> Option<(usize, usize)> {
243        let bytes = input.as_bytes();
244        let mut idx = from;
245        while idx < bytes.len() && bytes[idx].is_ascii_whitespace() {
246            idx += 1;
247        }
248        if idx >= bytes.len() {
249            return None;
250        }
251
252        let start = idx;
253        let mut in_single = false;
254        let mut in_double = false;
255        while idx < bytes.len() {
256            let byte = bytes[idx];
257            if in_single {
258                if byte == b'\'' {
259                    in_single = false;
260                }
261                idx += 1;
262                continue;
263            }
264            if in_double {
265                if byte == b'\\' && idx + 1 < bytes.len() {
266                    idx += 2;
267                    continue;
268                }
269                if byte == b'"' {
270                    in_double = false;
271                }
272                idx += 1;
273                continue;
274            }
275
276            match byte {
277                b'\'' => {
278                    in_single = true;
279                    idx += 1;
280                }
281                b'"' => {
282                    in_double = true;
283                    idx += 1;
284                }
285                b'\\' if idx + 1 < bytes.len() => {
286                    idx += 2;
287                }
288                _ if byte.is_ascii_whitespace() => break,
289                _ => {
290                    idx += 1;
291                }
292            }
293        }
294
295        Some((start, idx))
296    }
297
298    /// Install a tmux mouse-wheel override that only applies to `pane`.
299    fn install_binding_for_pane(pane: &str, key_name: &str, saved_line: Option<&str>) {
300        let fallback = saved_line
301            .and_then(|line| Self::binding_command(line, key_name))
302            .unwrap_or_default();
303        let args = Self::pane_scoped_binding_args(pane, key_name, fallback);
304        let _ = std::process::Command::new("tmux").args(&args).status();
305    }
306
307    fn pane_scoped_binding_args(pane: &str, key_name: &str, fallback: String) -> Vec<String> {
308        let condition = format!("#{{==:#{{pane_id}},{pane}}}");
309        vec![
310            "bind-key".to_string(),
311            "-T".to_string(),
312            "root".to_string(),
313            key_name.to_string(),
314            "if-shell".to_string(),
315            "-F".to_string(),
316            condition,
317            "send-keys -M".to_string(),
318            fallback,
319        ]
320    }
321
322    /// Restore the original binding for a wheel direction, or unbind if there
323    /// was no previous binding.
324    fn restore_binding(saved: Option<&str>, key_name: &str) {
325        if let Some(line) = saved {
326            // Restore the exact serialized bind-key command that tmux gave us.
327            Self::run_tmux_command_line(line);
328        } else {
329            // No previous binding — unbind to revert to tmux default behavior.
330            let _ = std::process::Command::new("tmux")
331                .args(["unbind-key", "-T", "root", key_name])
332                .stdin(std::process::Stdio::null())
333                .status();
334        }
335    }
336
337    fn run_tmux_command_line(command: &str) {
338        use std::io::Write as _;
339
340        let Ok(mut child) = std::process::Command::new("tmux")
341            .args(["source-file", "-"])
342            .stdin(std::process::Stdio::piped())
343            .spawn()
344        else {
345            return;
346        };
347
348        if let Some(mut stdin) = child.stdin.take() {
349            let _ = stdin.write_all(command.as_bytes());
350            let _ = stdin.write_all(b"\n");
351        }
352
353        let _ = child.wait();
354    }
355}
356
357impl Drop for TmuxWheelGuard {
358    fn drop(&mut self) {
359        Self::restore_binding(self.saved_wheel_up.as_deref(), "WheelUpPane");
360        Self::restore_binding(self.saved_wheel_down.as_deref(), "WheelDownPane");
361    }
362}
363
364// ============================================================================
365// Helpers
366// ============================================================================
367
368/// Compute the maximum visible items for overlay pickers (model selector,
369/// session picker, settings, branch picker, etc.) based on the terminal height.
370///
371/// The overlay typically needs ~8 rows of chrome: title, search field, divider,
372/// pagination hint, detail line, help footer, and margins.  We reserve that
373/// overhead and clamp the result to `[3, 30]` so the UI stays usable on very
374/// small terminals while allowing taller lists on large ones.
375fn overlay_max_visible(term_height: usize) -> usize {
376    const OVERLAY_CHROME_ROWS: usize = 8;
377    term_height.saturating_sub(OVERLAY_CHROME_ROWS).clamp(3, 30)
378}
379
380// ============================================================================
381// Slash Commands
382// ============================================================================
383
384impl PiApp {
385    /// Returns true when the viewport is currently anchored to the tail of the
386    /// conversation content (i.e. the user has not scrolled away from the bottom).
387    fn is_at_bottom(&self) -> bool {
388        let content = self.build_conversation_content();
389        let trimmed = content.trim_end();
390        let line_count = trimmed.lines().count();
391        let visible_rows = self.view_effective_conversation_height().max(1);
392        if line_count <= visible_rows {
393            return true;
394        }
395        let max_offset = line_count.saturating_sub(visible_rows);
396        self.conversation_viewport.y_offset() >= max_offset
397    }
398
399    /// Rebuild viewport content after conversation state changes.
400    /// If `follow_tail` is true the viewport is scrolled to the very bottom;
401    /// otherwise the current scroll position is preserved.
402    fn refresh_conversation_viewport(&mut self, follow_tail: bool) {
403        let vp_start = if self.frame_timing.enabled {
404            Some(std::time::Instant::now())
405        } else {
406            None
407        };
408
409        // When the user has scrolled away (follow_tail == false), preserve
410        // the absolute y_offset so new content appended at the bottom does
411        // not shift the lines the user is reading.
412        let saved_offset = if follow_tail {
413            None
414        } else {
415            Some(self.conversation_viewport.y_offset())
416        };
417
418        let content = self.build_conversation_content();
419        let trimmed = content.trim_end();
420        let effective = self.view_effective_conversation_height().max(1);
421        self.conversation_viewport.height = effective;
422        self.conversation_viewport.set_content(trimmed);
423
424        if follow_tail {
425            self.conversation_viewport.goto_bottom();
426            self.follow_stream_tail = true;
427        } else if let Some(offset) = saved_offset {
428            // Restore the exact scroll position. set_y_offset() clamps to
429            // max_y_offset internally, so this is safe even if content shrank.
430            self.conversation_viewport.set_y_offset(offset);
431        }
432
433        if let Some(start) = vp_start {
434            self.frame_timing
435                .record_viewport_sync(micros_as_u64(start.elapsed().as_micros()));
436        }
437    }
438
439    /// Scroll the conversation viewport to the bottom.
440    fn scroll_to_bottom(&mut self) {
441        self.refresh_conversation_viewport(true);
442    }
443
444    fn scroll_to_last_match(&mut self, needle: &str) {
445        let content = self.build_conversation_content();
446        let trimmed = content.trim_end();
447        let effective = self.view_effective_conversation_height().max(1);
448        self.conversation_viewport.height = effective;
449        self.conversation_viewport.set_content(trimmed);
450
451        let mut last_index = None;
452        for (idx, line) in trimmed.lines().enumerate() {
453            if line.contains(needle) {
454                last_index = Some(idx);
455            }
456        }
457
458        if let Some(idx) = last_index {
459            self.conversation_viewport.set_y_offset(idx);
460            self.follow_stream_tail = false;
461        } else {
462            self.conversation_viewport.goto_bottom();
463            self.follow_stream_tail = true;
464        }
465    }
466
467    /// Handle a mouse wheel event, routing it to the appropriate overlay or
468    /// the conversation viewport.  Returns `None` (no command needed).
469    fn handle_mouse_wheel(&mut self, is_up: bool) -> Option<Cmd> {
470        // Priority 1: tree UI captures everything.
471        if self.tree_ui.is_some() {
472            // Tree UI has its own scroll; we don't intercept here.
473            return None;
474        }
475
476        // Priority 2: model selector overlay.
477        if let Some(ref mut selector) = self.model_selector {
478            if is_up {
479                selector.select_prev();
480            } else {
481                selector.select_next();
482            }
483            return None;
484        }
485
486        // Priority 3: session picker overlay.
487        if let Some(ref mut picker) = self.session_picker {
488            if is_up {
489                picker.select_prev();
490            } else {
491                picker.select_next();
492            }
493            return None;
494        }
495
496        // Priority 4: settings UI overlay.
497        if let Some(ref mut settings) = self.settings_ui {
498            if is_up {
499                settings.select_prev();
500            } else {
501                settings.select_next();
502            }
503            return None;
504        }
505
506        // Priority 5: theme picker overlay.
507        if let Some(ref mut picker) = self.theme_picker {
508            if is_up {
509                picker.select_prev();
510            } else {
511                picker.select_next();
512            }
513            return None;
514        }
515
516        // Priority 6: branch picker overlay.
517        if let Some(ref mut picker) = self.branch_picker {
518            if is_up {
519                picker.select_prev();
520            } else {
521                picker.select_next();
522            }
523            return None;
524        }
525
526        // No overlay open: scroll the conversation viewport.
527        // Sync content before scrolling (same pattern as PageUp/PageDown).
528        let saved_offset = self.conversation_viewport.y_offset();
529        let content = self.build_conversation_content();
530        let effective = self.view_effective_conversation_height().max(1);
531        self.conversation_viewport.height = effective;
532        self.conversation_viewport.set_content(content.trim_end());
533        self.conversation_viewport.set_y_offset(saved_offset);
534
535        if is_up {
536            self.conversation_viewport.scroll_up(1);
537            self.follow_stream_tail = false;
538        } else {
539            self.conversation_viewport.scroll_down(1);
540            // Re-enable auto-follow if scrolled back to the bottom.
541            if self.is_at_bottom() {
542                self.follow_stream_tail = true;
543            }
544        }
545        None
546    }
547
548    fn apply_theme(&mut self, theme: Theme) {
549        self.theme = theme;
550        self.styles = self.theme.tui_styles();
551        self.markdown_style = self.theme.glamour_style_config();
552        self.markdown_style.code_block.block.margin =
553            Some(self.config.markdown_code_block_indent() as usize);
554        self.spinner =
555            SpinnerModel::with_spinner(spinners::dot()).style(self.styles.accent.clone());
556
557        self.message_render_cache.invalidate_all();
558        let content = self.build_conversation_content();
559        let effective = self.view_effective_conversation_height().max(1);
560        self.conversation_viewport.height = effective;
561        self.conversation_viewport.set_content(content.trim_end());
562    }
563
564    fn persist_project_theme(&self, theme_name: &str) -> crate::error::Result<()> {
565        let settings_path = self.cwd.join(Config::project_dir()).join("settings.json");
566        let mut settings = if settings_path.exists() {
567            let content = std::fs::read_to_string(&settings_path)?;
568            serde_json::from_str::<Value>(&content)?
569        } else {
570            json!({})
571        };
572
573        let obj = settings.as_object_mut().ok_or_else(|| {
574            crate::error::Error::config(format!(
575                "Settings file is not a JSON object: {}",
576                settings_path.display()
577            ))
578        })?;
579        obj.insert("theme".to_string(), Value::String(theme_name.to_string()));
580
581        if let Some(parent) = settings_path.parent() {
582            std::fs::create_dir_all(parent)?;
583        }
584        std::fs::write(settings_path, serde_json::to_string_pretty(&settings)?)?;
585        Ok(())
586    }
587
588    fn apply_queue_modes(&self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
589        if let Ok(mut queue) = self.message_queue.lock() {
590            queue.set_modes(steering_mode, follow_up_mode);
591        }
592        if let Ok(mut queue) = self.injected_queue.lock() {
593            queue.set_modes(steering_mode, follow_up_mode);
594        }
595
596        if let Ok(mut agent_guard) = self.agent.try_lock() {
597            agent_guard.set_queue_modes(steering_mode, follow_up_mode);
598            return;
599        }
600
601        let agent = Arc::clone(&self.agent);
602        let runtime_handle = self.runtime_handle.clone();
603        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
604        runtime_handle.spawn(async move {
605            if let Ok(mut agent_guard) = agent.lock(&task_cx).await {
606                agent_guard.set_queue_modes(steering_mode, follow_up_mode);
607            }
608        });
609    }
610
611    fn toggle_queue_mode_setting(&mut self, entry: SettingsUiEntry) {
612        let (key, current) = match entry {
613            SettingsUiEntry::SteeringMode => ("steeringMode", self.config.steering_queue_mode()),
614            SettingsUiEntry::FollowUpMode => ("followUpMode", self.config.follow_up_queue_mode()),
615            _ => return,
616        };
617
618        let next = match current {
619            QueueMode::All => QueueMode::OneAtATime,
620            QueueMode::OneAtATime => QueueMode::All,
621        };
622
623        let patch = match entry {
624            SettingsUiEntry::SteeringMode => json!({ "steeringMode": next.as_str() }),
625            SettingsUiEntry::FollowUpMode => json!({ "followUpMode": next.as_str() }),
626            _ => json!({}),
627        };
628
629        let global_dir = Config::global_dir();
630        if let Err(err) =
631            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
632        {
633            self.status_message = Some(format!("Failed to update {key}: {err}"));
634            return;
635        }
636
637        match entry {
638            SettingsUiEntry::SteeringMode => {
639                self.config.steering_mode = Some(next.as_str().to_string());
640            }
641            SettingsUiEntry::FollowUpMode => {
642                self.config.follow_up_mode = Some(next.as_str().to_string());
643            }
644            _ => {}
645        }
646
647        let steering_mode = self.config.steering_queue_mode();
648        let follow_up_mode = self.config.follow_up_queue_mode();
649        self.apply_queue_modes(steering_mode, follow_up_mode);
650        self.status_message = Some(format!("Updated {key}: {}", next.as_str()));
651    }
652
653    fn persist_project_settings_patch(&mut self, key: &str, patch: Value) -> bool {
654        let global_dir = Config::global_dir();
655        if let Err(err) =
656            Config::patch_settings_with_roots(SettingsScope::Project, &global_dir, &self.cwd, patch)
657        {
658            self.status_message = Some(format!("Failed to update {key}: {err}"));
659            return false;
660        }
661        true
662    }
663
664    fn effective_show_hardware_cursor(&self) -> bool {
665        self.config.show_hardware_cursor.unwrap_or_else(|| {
666            std::env::var("PI_HARDWARE_CURSOR")
667                .ok()
668                .is_some_and(|val| val == "1")
669        })
670    }
671
672    fn effective_default_permissive(&self) -> bool {
673        self.config
674            .extension_policy
675            .as_ref()
676            .and_then(|policy| policy.default_permissive)
677            .unwrap_or(true)
678    }
679
680    fn has_loaded_extensions(&self) -> bool {
681        self.extensions
682            .as_ref()
683            .is_some_and(ExtensionManager::has_loaded_extensions)
684    }
685
686    fn default_permissive_changes_require_extension_restart(&self) -> bool {
687        self.has_loaded_extensions()
688    }
689
690    fn default_permissive_update_status(&self, next: bool) -> String {
691        let mut status = format!(
692            "Updated extensionPolicy.defaultPermissive: {}",
693            bool_label(next)
694        );
695        if self.default_permissive_changes_require_extension_restart() {
696            status.push_str(" (restart active extensions/session to apply)");
697        }
698        status
699    }
700
701    fn apply_hardware_cursor(show: bool) {
702        let mut stdout = std::io::stdout();
703        if show {
704            let _ = crossterm::execute!(stdout, cursor::Show);
705        } else {
706            let _ = crossterm::execute!(stdout, cursor::Hide);
707        }
708    }
709
710    #[allow(clippy::too_many_lines)]
711    fn toggle_settings_entry(&mut self, entry: SettingsUiEntry) {
712        match entry {
713            SettingsUiEntry::SteeringMode | SettingsUiEntry::FollowUpMode => {
714                self.toggle_queue_mode_setting(entry);
715            }
716            SettingsUiEntry::DefaultPermissive => {
717                let next = !self.effective_default_permissive();
718                if self.persist_project_settings_patch(
719                    "extensionPolicy.defaultPermissive",
720                    json!({ "extensionPolicy": { "defaultPermissive": next } }),
721                ) {
722                    let policy = self
723                        .config
724                        .extension_policy
725                        .get_or_insert_with(ExtensionPolicyConfig::default);
726                    policy.default_permissive = Some(next);
727                    self.status_message = Some(self.default_permissive_update_status(next));
728                }
729            }
730            SettingsUiEntry::QuietStartup => {
731                let next = !self.config.quiet_startup.unwrap_or(false);
732                if self.persist_project_settings_patch(
733                    "quietStartup",
734                    json!({ "quiet_startup": next }),
735                ) {
736                    self.config.quiet_startup = Some(next);
737                    self.status_message =
738                        Some(format!("Updated quietStartup: {}", bool_label(next)));
739                }
740            }
741            SettingsUiEntry::CollapseChangelog => {
742                let next = !self.config.collapse_changelog.unwrap_or(false);
743                if self.persist_project_settings_patch(
744                    "collapseChangelog",
745                    json!({ "collapse_changelog": next }),
746                ) {
747                    self.config.collapse_changelog = Some(next);
748                    self.status_message =
749                        Some(format!("Updated collapseChangelog: {}", bool_label(next)));
750                }
751            }
752            SettingsUiEntry::HideThinkingBlock => {
753                let next = !self.config.hide_thinking_block.unwrap_or(false);
754                if self.persist_project_settings_patch(
755                    "hideThinkingBlock",
756                    json!({ "hide_thinking_block": next }),
757                ) {
758                    self.config.hide_thinking_block = Some(next);
759                    self.thinking_visible = !next;
760                    self.message_render_cache.invalidate_all();
761                    self.scroll_to_bottom();
762                    self.status_message =
763                        Some(format!("Updated hideThinkingBlock: {}", bool_label(next)));
764                }
765            }
766            SettingsUiEntry::ShowHardwareCursor => {
767                let next = !self.effective_show_hardware_cursor();
768                if self.persist_project_settings_patch(
769                    "showHardwareCursor",
770                    json!({ "show_hardware_cursor": next }),
771                ) {
772                    self.config.show_hardware_cursor = Some(next);
773                    Self::apply_hardware_cursor(next);
774                    self.status_message =
775                        Some(format!("Updated showHardwareCursor: {}", bool_label(next)));
776                }
777            }
778            SettingsUiEntry::DoubleEscapeAction => {
779                let current = self
780                    .config
781                    .double_escape_action
782                    .as_deref()
783                    .unwrap_or("tree");
784                let next = if current.eq_ignore_ascii_case("tree") {
785                    "fork"
786                } else if current.eq_ignore_ascii_case("fork") {
787                    "none"
788                } else {
789                    "tree"
790                };
791                if self.persist_project_settings_patch(
792                    "doubleEscapeAction",
793                    json!({ "double_escape_action": next }),
794                ) {
795                    self.config.double_escape_action = Some(next.to_string());
796                    self.last_escape_time = None;
797                    self.status_message = Some(format!("Updated doubleEscapeAction: {next}"));
798                }
799            }
800            SettingsUiEntry::EditorPaddingX => {
801                let current = self.editor_padding_x.min(3);
802                let next = match current {
803                    0 => 1,
804                    1 => 2,
805                    2 => 3,
806                    _ => 0,
807                };
808                if self.persist_project_settings_patch(
809                    "editorPaddingX",
810                    json!({ "editor_padding_x": next }),
811                ) {
812                    self.config.editor_padding_x = u32::try_from(next).ok();
813                    self.editor_padding_x = next;
814                    self.input
815                        .set_width(self.term_width.saturating_sub(5 + self.editor_padding_x));
816                    self.scroll_to_bottom();
817                    self.status_message = Some(format!("Updated editorPaddingX: {next}"));
818                }
819            }
820            SettingsUiEntry::AutocompleteMaxVisible => {
821                let cycle = [3usize, 5, 8, 10, 12, 15, 20];
822                let current = self.autocomplete.max_visible;
823                let next = cycle
824                    .iter()
825                    .position(|value| *value == current)
826                    .map_or(cycle[0], |idx| cycle[(idx + 1) % cycle.len()]);
827                if self.persist_project_settings_patch(
828                    "autocompleteMaxVisible",
829                    json!({ "autocomplete_max_visible": next }),
830                ) {
831                    self.config.autocomplete_max_visible = u32::try_from(next).ok();
832                    self.autocomplete.max_visible = next;
833                    self.status_message = Some(format!("Updated autocompleteMaxVisible: {next}"));
834                }
835            }
836            SettingsUiEntry::Theme => {
837                self.settings_ui = None;
838                let mut picker = ThemePickerOverlay::new(&self.cwd);
839                picker.max_visible = overlay_max_visible(self.term_height);
840                self.theme_picker = Some(picker);
841            }
842            SettingsUiEntry::Summary => {}
843        }
844    }
845
846    // ========================================================================
847    // Memory pressure actions (PERF-6)
848    // ========================================================================
849
850    /// Run memory pressure actions: progressive collapse (Pressure) and
851    /// conversation truncation (Critical). Called from update_inner().
852    fn run_memory_pressure_actions(&mut self) {
853        let level = self.memory_monitor.level;
854
855        // Progressive collapse: one tool output per second, oldest first.
856        if self.memory_monitor.collapsing
857            && self.memory_monitor.last_collapse.elapsed() >= std::time::Duration::from_secs(1)
858        {
859            if let Some(idx) = self.find_next_uncollapsed_tool_output() {
860                self.messages[idx].collapsed = true;
861                let placeholder = "[tool output collapsed due to memory pressure]".to_string();
862                self.messages[idx].content = placeholder;
863                self.messages[idx].thinking = None;
864                self.memory_monitor.next_collapse_index = idx + 1;
865                self.memory_monitor.last_collapse = std::time::Instant::now();
866                self.memory_monitor.resample_now();
867            } else {
868                self.memory_monitor.collapsing = false;
869            }
870        }
871
872        // Pressure level: remove thinking from messages older than last 10 turns.
873        if level == MemoryLevel::Pressure || level == MemoryLevel::Critical {
874            let msg_count = self.messages.len();
875            if msg_count > 10 {
876                for msg in &mut self.messages[..msg_count - 10] {
877                    if msg.thinking.is_some() {
878                        msg.thinking = None;
879                    }
880                }
881            }
882        }
883
884        // Critical: truncate old messages (keep last CRITICAL_KEEP_MESSAGES).
885        if level == MemoryLevel::Critical && !self.memory_monitor.truncated {
886            let msg_count = self.messages.len();
887            if msg_count > CRITICAL_KEEP_MESSAGES {
888                let remove_count = msg_count - CRITICAL_KEEP_MESSAGES;
889                self.messages.drain(..remove_count);
890                self.messages.insert(
891                    0,
892                    ConversationMessage::new(
893                        MessageRole::System,
894                        "[conversation history truncated due to memory pressure — see session file for full history]".to_string(),
895                        None,
896                    ),
897                );
898                self.memory_monitor.next_collapse_index = 0;
899                self.message_render_cache.clear();
900            }
901            self.memory_monitor.truncated = true;
902            self.memory_monitor.resample_now();
903        }
904    }
905
906    /// Find the next uncollapsed Tool message starting from `next_collapse_index`.
907    fn find_next_uncollapsed_tool_output(&self) -> Option<usize> {
908        let start = self.memory_monitor.next_collapse_index;
909        (start..self.messages.len())
910            .find(|&i| self.messages[i].role == MessageRole::Tool && !self.messages[i].collapsed)
911    }
912
913    fn format_settings_summary(&self) -> String {
914        let theme_setting = self
915            .config
916            .theme
917            .as_deref()
918            .unwrap_or("")
919            .trim()
920            .to_string();
921        let theme_setting = if theme_setting.is_empty() {
922            "(default)".to_string()
923        } else {
924            theme_setting
925        };
926
927        let compaction_enabled = self.config.compaction_enabled();
928        let reserve_tokens = self.config.compaction_reserve_tokens();
929        let keep_recent = self.config.compaction_keep_recent_tokens();
930        let steering = self.config.steering_queue_mode();
931        let follow_up = self.config.follow_up_queue_mode();
932        let default_permissive = self.effective_default_permissive();
933        let quiet_startup = self.config.quiet_startup.unwrap_or(false);
934        let collapse_changelog = self.config.collapse_changelog.unwrap_or(false);
935        let hide_thinking_block = self.config.hide_thinking_block.unwrap_or(false);
936        let show_hardware_cursor = self.effective_show_hardware_cursor();
937        let double_escape_action = self
938            .config
939            .double_escape_action
940            .as_deref()
941            .unwrap_or("tree");
942
943        let mut output = String::new();
944        let _ = writeln!(output, "Settings:");
945        let _ = writeln!(
946            output,
947            "  theme: {} (config: {})",
948            self.theme.name, theme_setting
949        );
950        let _ = writeln!(output, "  model: {}", self.model);
951        let _ = writeln!(
952            output,
953            "  compaction: {compaction_enabled} (reserve={reserve_tokens}, keepRecent={keep_recent})"
954        );
955        let _ = writeln!(output, "  steeringMode: {}", steering.as_str());
956        let _ = writeln!(output, "  followUpMode: {}", follow_up.as_str());
957        let _ = writeln!(
958            output,
959            "  extensionPolicy.defaultPermissive: {}{}",
960            bool_label(default_permissive),
961            if self.default_permissive_changes_require_extension_restart() {
962                " (future changes apply after extension restart)"
963            } else {
964                ""
965            }
966        );
967        let _ = writeln!(output, "  quietStartup: {}", bool_label(quiet_startup));
968        let _ = writeln!(
969            output,
970            "  collapseChangelog: {}",
971            bool_label(collapse_changelog)
972        );
973        let _ = writeln!(
974            output,
975            "  hideThinkingBlock: {}",
976            bool_label(hide_thinking_block)
977        );
978        let _ = writeln!(
979            output,
980            "  showHardwareCursor: {}",
981            bool_label(show_hardware_cursor)
982        );
983        let _ = writeln!(output, "  doubleEscapeAction: {double_escape_action}");
984        let _ = writeln!(output, "  editorPaddingX: {}", self.editor_padding_x);
985        let _ = writeln!(
986            output,
987            "  autocompleteMaxVisible: {}",
988            self.autocomplete.max_visible
989        );
990        let _ = writeln!(
991            output,
992            "  skillCommands: {}",
993            if self.config.enable_skill_commands() {
994                "enabled"
995            } else {
996                "disabled"
997            }
998        );
999
1000        let _ = writeln!(output, "\nResources:");
1001        let _ = writeln!(output, "  skills: {}", self.resources.skills().len());
1002        let _ = writeln!(output, "  prompts: {}", self.resources.prompts().len());
1003        let _ = writeln!(output, "  themes: {}", self.resources.themes().len());
1004
1005        let skill_diags = self.resources.skill_diagnostics().len();
1006        let prompt_diags = self.resources.prompt_diagnostics().len();
1007        let theme_diags = self.resources.theme_diagnostics().len();
1008        if skill_diags + prompt_diags + theme_diags > 0 {
1009            let _ = writeln!(output, "\nDiagnostics:");
1010            let _ = writeln!(output, "  skills: {skill_diags}");
1011            let _ = writeln!(output, "  prompts: {prompt_diags}");
1012            let _ = writeln!(output, "  themes: {theme_diags}");
1013        }
1014
1015        output
1016    }
1017
1018    fn default_export_path(&self, session: &Session) -> PathBuf {
1019        if let Some(path) = session.path.as_ref() {
1020            let stem = path
1021                .file_stem()
1022                .and_then(|s| s.to_str())
1023                .unwrap_or("session");
1024            return self.cwd.join(format!("pi-session-{stem}.html"));
1025        }
1026        let id = crate::session_picker::truncate_session_id(&session.header.id, 8);
1027        self.cwd.join(format!("pi-session-unsaved-{id}.html"))
1028    }
1029
1030    fn resolve_output_path(&self, raw: &str) -> PathBuf {
1031        let raw = raw.trim();
1032        if raw.is_empty() {
1033            return self.cwd.join("pi-session.html");
1034        }
1035        let path = PathBuf::from(raw);
1036        if path.is_absolute() {
1037            path
1038        } else {
1039            self.cwd.join(path)
1040        }
1041    }
1042
1043    fn spawn_save_session(&self) {
1044        if !self.save_enabled {
1045            return;
1046        }
1047
1048        let session = Arc::clone(&self.session);
1049        let event_tx = self.event_tx.clone();
1050        let runtime_handle = self.runtime_handle.clone();
1051        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1052        runtime_handle.spawn(async move {
1053            let mut session_guard = match session.lock(&task_cx).await {
1054                Ok(guard) => guard,
1055                Err(err) => {
1056                    let _ = crate::interactive::enqueue_pi_event(
1057                        &event_tx,
1058                        &Cx::for_request(),
1059                        PiMsg::AgentError(format!("Failed to lock session: {err}")),
1060                    )
1061                    .await;
1062                    return;
1063                }
1064            };
1065
1066            if let Err(err) = session_guard.save().await {
1067                let _ = crate::interactive::enqueue_pi_event(
1068                    &event_tx,
1069                    &Cx::for_request(),
1070                    PiMsg::AgentError(format!("Failed to save session: {err}")),
1071                )
1072                .await;
1073            }
1074        });
1075    }
1076
1077    fn maybe_trigger_autocomplete(&mut self) {
1078        if !matches!(self.agent_state, AgentState::Idle)
1079            || self.session_picker.is_some()
1080            || self.settings_ui.is_some()
1081        {
1082            self.autocomplete.close();
1083            return;
1084        }
1085
1086        let text = self.input.value();
1087        if text.trim().is_empty() {
1088            self.autocomplete.close();
1089            return;
1090        }
1091
1092        // Autocomplete provider expects a byte offset cursor.
1093        let cursor = self.input.cursor_byte_offset();
1094        let response = self.autocomplete.provider.suggest(&text, cursor);
1095        // Path completion is Tab-triggered to avoid noisy dropdowns for URL-like tokens.
1096        if response
1097            .items
1098            .iter()
1099            .all(|item| item.kind == AutocompleteItemKind::Path)
1100        {
1101            self.autocomplete.close();
1102            return;
1103        }
1104        self.autocomplete.open_with(response);
1105    }
1106
1107    fn trigger_autocomplete(&mut self) {
1108        self.maybe_trigger_autocomplete();
1109    }
1110
1111    /// Compute the conversation viewport height based on the current UI chrome.
1112    ///
1113    /// This delegates to [`view_effective_conversation_height`] so viewport
1114    /// scroll math stays aligned with the rows actually rendered in `view()`.
1115    fn conversation_viewport_height(&self) -> usize {
1116        self.view_effective_conversation_height()
1117    }
1118
1119    /// Return whether the generic "Processing..." spinner row should be shown.
1120    ///
1121    /// Once provider text/thinking deltas are streaming, that output already
1122    /// acts as progress feedback; suppressing the extra animated status row
1123    /// reduces redraw churn and visible flicker.
1124    fn show_processing_status_spinner(&self) -> bool {
1125        if matches!(self.agent_state, AgentState::Idle) || self.current_tool.is_some() {
1126            return false;
1127        }
1128
1129        let has_visible_stream_progress = !self.current_response.is_empty()
1130            || (self.thinking_visible && !self.current_thinking.is_empty());
1131        !has_visible_stream_progress
1132    }
1133
1134    /// Return whether any spinner row is currently visible in `view()`.
1135    ///
1136    /// The spinner is rendered either for tool execution progress, or for the
1137    /// generic processing state before visible stream output appears.
1138    fn spinner_visible(&self) -> bool {
1139        if matches!(self.agent_state, AgentState::Idle) {
1140            return false;
1141        }
1142        self.current_tool.is_some() || self.show_processing_status_spinner()
1143    }
1144
1145    /// Return whether the normal editor input area should be visible.
1146    ///
1147    /// Keeping this in one place prevents overlay/input drift between
1148    /// rendering, viewport sizing, and keyboard dispatch.
1149    const fn editor_input_is_available(&self) -> bool {
1150        matches!(self.agent_state, AgentState::Idle)
1151            && self.tree_ui.is_none()
1152            && self.session_picker.is_none()
1153            && self.settings_ui.is_none()
1154            && self.theme_picker.is_none()
1155            && self.capability_prompt.is_none()
1156            && self.extension_custom_overlay.is_none()
1157            && self.branch_picker.is_none()
1158            && self.model_selector.is_none()
1159    }
1160
1161    /// Return whether a custom extension overlay should currently receive
1162    /// keyboard input.
1163    ///
1164    /// Higher-priority modal overlays must win when they are present;
1165    /// otherwise the prompt renders but can never be answered.
1166    const fn custom_overlay_input_is_available(&self) -> bool {
1167        self.extension_custom_active
1168            && self.tree_ui.is_none()
1169            && self.session_picker.is_none()
1170            && self.settings_ui.is_none()
1171            && self.theme_picker.is_none()
1172            && self.capability_prompt.is_none()
1173            && self.branch_picker.is_none()
1174            && self.model_selector.is_none()
1175    }
1176
1177    /// Approximate how many rows the custom extension overlay renders.
1178    ///
1179    /// `render_extension_custom_overlay()` emits:
1180    /// - a leading blank spacer row plus the title row
1181    /// - the source row
1182    /// - either the waiting line or the visible frame tail
1183    /// - the help row
1184    fn extension_custom_overlay_rows(&self) -> usize {
1185        let Some(overlay) = self.extension_custom_overlay.as_ref() else {
1186            return 0;
1187        };
1188
1189        let max_lines = self.term_height.saturating_sub(12).max(4);
1190        let visible_lines = overlay.lines.len().min(max_lines).max(1);
1191        4 + visible_lines
1192    }
1193
1194    /// Compute the effective conversation viewport height for the current
1195    /// render frame, accounting for conditional chrome (scroll indicator,
1196    /// tool status, status message) that reduce available space.
1197    ///
1198    /// Used in [`view()`] for conversation line slicing so the total output
1199    /// never exceeds `term_height` rows.  The stored
1200    /// `conversation_viewport.height` still drives scroll-position management.
1201    fn view_effective_conversation_height(&self) -> usize {
1202        // Fixed chrome:
1203        // header(4) = title/model + hints + resources + spacer line
1204        // footer(2) = blank line + footer line
1205        let mut chrome: usize = 4 + 2;
1206
1207        // Budget 1 row for the scroll indicator.  Slightly conservative
1208        // when content is short, but prevents the off-by-one that triggers
1209        // terminal scrolling.
1210        chrome += 1;
1211
1212        // Tool status: "\n  spinner Running {tool} ...\n" = 2 rows.
1213        if self.current_tool.is_some() {
1214            chrome += 2;
1215        }
1216
1217        // Status message: "\n  {status}\n" = 2 rows.
1218        if self.status_message.is_some() {
1219            chrome += 2;
1220        }
1221
1222        // Capability prompt overlay: ~8 lines (title, ext name, desc, blank, buttons, timer, help, blank).
1223        if self.capability_prompt.is_some() {
1224            chrome += 8;
1225        }
1226
1227        // Custom extension overlay: spacer + title + source + content/help.
1228        chrome += self.extension_custom_overlay_rows();
1229
1230        // Branch picker overlay: header + N visible branches + help line + padding.
1231        if let Some(ref picker) = self.branch_picker {
1232            let visible = picker.branches.len().min(picker.max_visible);
1233            chrome += 3 + visible + 2; // title + header + separator + items + help + blank
1234        }
1235
1236        // Model selector overlay: title + config-only hint + search + separator + items + detail + help + padding.
1237        if let Some(ref selector) = self.model_selector {
1238            let visible = selector.max_visible().min(selector.filtered_len().max(1));
1239            // ~6 lines of chrome (title, optional hint, search, separator, detail/status, help)
1240            chrome += visible + 6;
1241        }
1242
1243        // Session picker overlay: title + search + separator + items + help + padding.
1244        if let Some(ref picker) = self.session_picker {
1245            let visible = picker.sessions.len().min(picker.max_visible);
1246            chrome += visible + 6; // title + blank + search + separator + items + help + blank
1247        }
1248
1249        // Settings UI overlay: title + items + help + padding.
1250        if let Some(ref settings) = self.settings_ui {
1251            let visible = settings.entries.len().min(settings.max_visible);
1252            chrome += visible + 5; // title + blank + items + help + blank
1253        }
1254
1255        // Theme picker overlay: title + items + help + padding.
1256        if let Some(ref picker) = self.theme_picker {
1257            let visible = picker.items.len().min(picker.max_visible);
1258            chrome += visible + 5; // title + blank + items + help + blank
1259        }
1260
1261        // Safety margin: when any overlay is active, add extra rows to absorb
1262        // styling escape-sequence overhead and occasional line-wrap edge cases
1263        // that can push content past the terminal bottom.
1264        let any_overlay = self.session_picker.is_some()
1265            || self.settings_ui.is_some()
1266            || self.theme_picker.is_some()
1267            || self.capability_prompt.is_some()
1268            || self.extension_custom_overlay.is_some()
1269            || self.branch_picker.is_some()
1270            || self.model_selector.is_some();
1271        if any_overlay {
1272            chrome += 2;
1273        }
1274
1275        // Input area vs processing spinner.
1276        if self.editor_input_is_available() {
1277            // render_input: "\n  header\n" (2 rows) + input.height() rows.
1278            chrome += 2 + self.input.height();
1279
1280            // Autocomplete dropdown chrome when open: top border(1) +
1281            // items(visible_count) + description(1) + pagination(1) +
1282            // bottom border(1) + help(1).  Budget for the dropdown so
1283            // the conversation viewport shrinks to make room.
1284            if self.autocomplete.open && !self.autocomplete.items.is_empty() {
1285                let visible = self
1286                    .autocomplete
1287                    .max_visible
1288                    .min(self.autocomplete.items.len());
1289                // 5 = top border + possible description + possible pagination
1290                //     + bottom border + help line
1291                chrome += visible + 5;
1292            }
1293        } else if self.show_processing_status_spinner() {
1294            // Processing spinner: "\n  spinner Processing...\n" = 2 rows.
1295            chrome += 2;
1296        }
1297
1298        self.term_height.saturating_sub(chrome)
1299    }
1300
1301    /// Set the input area height and recalculate the conversation viewport
1302    /// so the total layout fits the terminal.
1303    fn set_input_height(&mut self, h: usize) {
1304        self.input.set_height(h);
1305        self.resize_conversation_viewport();
1306    }
1307
1308    /// Rebuild the conversation viewport after a height change (terminal resize or
1309    /// input area growth). Preserves mouse-wheel settings and scroll position.
1310    fn resize_conversation_viewport(&mut self) {
1311        let viewport_height = self.conversation_viewport_height();
1312        let mut viewport = Viewport::new(self.term_width.saturating_sub(2), viewport_height);
1313        viewport.mouse_wheel_enabled = true;
1314        viewport.mouse_wheel_delta = 1;
1315        self.conversation_viewport = viewport;
1316        self.scroll_to_bottom();
1317    }
1318
1319    pub fn set_terminal_size(&mut self, width: usize, height: usize) {
1320        let test_mode = std::env::var_os("PI_TEST_MODE").is_some();
1321        let previous_height = self.term_height;
1322        self.term_width = width.max(1);
1323        self.term_height = height.max(1);
1324        self.input
1325            .set_width(self.term_width.saturating_sub(5 + self.editor_padding_x));
1326
1327        if !test_mode
1328            && self.term_height < previous_height
1329            && self.config.terminal_clear_on_shrink()
1330        {
1331            let _ = crossterm::execute!(
1332                std::io::stdout(),
1333                terminal::Clear(terminal::ClearType::Purge)
1334            );
1335        }
1336
1337        self.message_render_cache.invalidate_all();
1338        self.resize_conversation_viewport();
1339
1340        // Adapt open overlay pickers to the new terminal height.
1341        let max_vis = overlay_max_visible(self.term_height);
1342        if let Some(ref mut selector) = self.model_selector {
1343            selector.set_max_visible(max_vis);
1344        }
1345        if let Some(ref mut picker) = self.session_picker {
1346            picker.max_visible = max_vis;
1347        }
1348        if let Some(ref mut settings) = self.settings_ui {
1349            settings.max_visible = max_vis;
1350        }
1351        if let Some(ref mut picker) = self.theme_picker {
1352            picker.max_visible = max_vis;
1353        }
1354        if let Some(ref mut picker) = self.branch_picker {
1355            picker.max_visible = max_vis;
1356        }
1357    }
1358
1359    fn accept_autocomplete(&mut self, item: &AutocompleteItem) {
1360        let text = self.input.value();
1361        let range = self.autocomplete.replace_range.clone();
1362
1363        // Guard against stale range if editor content changed since autocomplete was triggered.
1364        let mut start = range.start.min(text.len());
1365        while start > 0 && !text.is_char_boundary(start) {
1366            start -= 1;
1367        }
1368        let mut end = range.end.min(text.len()).max(start);
1369        while end < text.len() && !text.is_char_boundary(end) {
1370            end += 1;
1371        }
1372
1373        let mut new_text = String::with_capacity(text.len().saturating_add(item.insert.len()));
1374        new_text.push_str(&text[..start]);
1375        new_text.push_str(&item.insert);
1376        new_text.push_str(&text[end..]);
1377
1378        self.input.set_value(&new_text);
1379        self.input.cursor_end();
1380    }
1381
1382    fn extract_file_references(&mut self, message: &str) -> (String, Vec<String>) {
1383        let mut cleaned = String::with_capacity(message.len());
1384        let mut file_args = Vec::new();
1385        let mut idx = 0usize;
1386
1387        while idx < message.len() {
1388            let ch = message[idx..].chars().next().unwrap_or(' ');
1389            if ch == '@' && is_file_ref_boundary(message, idx) {
1390                let token_start = idx + ch.len_utf8();
1391                let parsed = parse_quoted_file_ref(message, token_start);
1392                let (path, trailing, token_end) = parsed.unwrap_or_else(|| {
1393                    let (token, token_end) = next_non_whitespace_token(message, token_start);
1394                    let (path, trailing) = split_trailing_punct(token);
1395                    (path.to_string(), trailing.to_string(), token_end)
1396                });
1397
1398                if !path.is_empty() {
1399                    let resolved =
1400                        self.autocomplete
1401                            .provider
1402                            .resolve_file_ref(&path)
1403                            .or_else(|| {
1404                                let resolved_path = resolve_read_path(&path, &self.cwd);
1405                                resolved_path.exists().then(|| path.clone())
1406                            });
1407
1408                    if let Some(resolved) = resolved {
1409                        file_args.push(resolved);
1410                        let mut next_idx = token_end;
1411                        if !trailing.is_empty() {
1412                            Self::trim_trailing_horizontal_whitespace(&mut cleaned);
1413                        } else if message[next_idx..]
1414                            .chars()
1415                            .next()
1416                            .is_some_and(Self::is_horizontal_whitespace)
1417                        {
1418                            while message[next_idx..]
1419                                .chars()
1420                                .next()
1421                                .is_some_and(Self::is_horizontal_whitespace)
1422                            {
1423                                next_idx +=
1424                                    message[next_idx..].chars().next().map_or(0, char::len_utf8);
1425                            }
1426                        } else if Self::trailing_line_is_blank(&cleaned)
1427                            && message[next_idx..]
1428                                .chars()
1429                                .next()
1430                                .is_some_and(Self::is_linebreak)
1431                        {
1432                            Self::trim_trailing_horizontal_whitespace(&mut cleaned);
1433                            next_idx += Self::consume_single_linebreak(message, next_idx);
1434                        }
1435                        cleaned.push_str(&trailing);
1436                        idx = next_idx;
1437                        continue;
1438                    }
1439                }
1440            }
1441
1442            cleaned.push(ch);
1443            idx += ch.len_utf8();
1444        }
1445
1446        (cleaned, file_args)
1447    }
1448
1449    const fn is_linebreak(ch: char) -> bool {
1450        matches!(ch, '\n' | '\r')
1451    }
1452
1453    const fn is_horizontal_whitespace(ch: char) -> bool {
1454        matches!(ch, ' ' | '\t')
1455    }
1456
1457    fn trim_trailing_horizontal_whitespace(text: &mut String) {
1458        while text
1459            .chars()
1460            .last()
1461            .is_some_and(Self::is_horizontal_whitespace)
1462        {
1463            text.pop();
1464        }
1465    }
1466
1467    fn trailing_line_is_blank(text: &str) -> bool {
1468        if let Some((line_start, linebreak)) = text
1469            .char_indices()
1470            .rev()
1471            .find(|(_, ch)| Self::is_linebreak(*ch))
1472        {
1473            let start = line_start + linebreak.len_utf8();
1474            return text[start..].chars().all(Self::is_horizontal_whitespace);
1475        }
1476
1477        text.chars().all(Self::is_horizontal_whitespace)
1478    }
1479
1480    fn consume_single_linebreak(text: &str, start: usize) -> usize {
1481        if start >= text.len() {
1482            return 0;
1483        }
1484
1485        let Some(first) = text[start..].chars().next() else {
1486            return 0;
1487        };
1488        if !Self::is_linebreak(first) {
1489            return 0;
1490        }
1491
1492        let first_len = first.len_utf8();
1493        if first == '\r' && text[start + first_len..].starts_with('\n') {
1494            return first_len + '\n'.len_utf8();
1495        }
1496
1497        first_len
1498    }
1499
1500    #[allow(clippy::too_many_lines)]
1501    fn load_session_from_path(&mut self, path: &str) -> Option<Cmd> {
1502        let path = path.to_string();
1503        let session = Arc::clone(&self.session);
1504        let agent = Arc::clone(&self.agent);
1505        let extensions = self.extensions.clone();
1506        let event_tx = self.event_tx.clone();
1507        let runtime_handle = self.runtime_handle.clone();
1508
1509        let (session_dir, previous_session_file) = {
1510            let Ok(guard) = self.session.try_lock() else {
1511                self.status_message = Some("Session busy; try again".to_string());
1512                return None;
1513            };
1514            (
1515                guard.session_dir.clone(),
1516                guard.path.as_ref().map(|p| p.display().to_string()),
1517            )
1518        };
1519
1520        let task_cx = Cx::current().unwrap_or_else(Cx::for_request);
1521        runtime_handle.spawn(async move {
1522            if let Some(manager) = extensions.clone() {
1523                let cancelled = manager
1524                    .dispatch_cancellable_event(
1525                        ExtensionEventName::SessionBeforeSwitch,
1526                        Some(json!({
1527                            "reason": "resume",
1528                            "targetSessionFile": path.clone(),
1529                        })),
1530                        EXTENSION_EVENT_TIMEOUT_MS,
1531                    )
1532                    .await
1533                    .unwrap_or(false);
1534                if cancelled {
1535                    let _ = crate::interactive::enqueue_pi_event(
1536                        &event_tx,
1537                        &task_cx,
1538                        PiMsg::System("Session switch cancelled by extension".to_string()),
1539                    )
1540                    .await;
1541                    return;
1542                }
1543            }
1544
1545            let mut loaded_session = match Session::open(&path).await {
1546                Ok(session) => session,
1547                Err(err) => {
1548                    let _ = crate::interactive::enqueue_pi_event(
1549                        &event_tx,
1550                        &task_cx,
1551                        PiMsg::AgentError(format!("Failed to open session: {err}")),
1552                    )
1553                    .await;
1554                    return;
1555                }
1556            };
1557            let new_session_id = loaded_session.header.id.clone();
1558            loaded_session.session_dir = session_dir;
1559
1560            let messages_for_agent = loaded_session.to_messages_for_current_path();
1561
1562            // Replace the session.
1563            {
1564                let mut session_guard = match session.lock(&task_cx).await {
1565                    Ok(guard) => guard,
1566                    Err(err) => {
1567                        let _ = crate::interactive::enqueue_pi_event(
1568                            &event_tx,
1569                            &Cx::for_request(),
1570                            PiMsg::AgentError(format!("Failed to lock session: {err}")),
1571                        )
1572                        .await;
1573                        return;
1574                    }
1575                };
1576                *session_guard = loaded_session;
1577            }
1578
1579            // Update the agent messages.
1580            {
1581                let mut agent_guard = match agent.lock(&task_cx).await {
1582                    Ok(guard) => guard,
1583                    Err(err) => {
1584                        let _ = crate::interactive::enqueue_pi_event(
1585                            &event_tx,
1586                            &task_cx,
1587                            PiMsg::AgentError(format!("Failed to lock agent: {err}")),
1588                        )
1589                        .await;
1590                        return;
1591                    }
1592                };
1593                agent_guard.replace_messages(messages_for_agent);
1594            }
1595
1596            let (messages, usage) = {
1597                let session_guard = match session.lock(&task_cx).await {
1598                    Ok(guard) => guard,
1599                    Err(err) => {
1600                        let _ = crate::interactive::enqueue_pi_event(
1601                            &event_tx,
1602                            &Cx::for_request(),
1603                            PiMsg::AgentError(format!("Failed to lock session: {err}")),
1604                        )
1605                        .await;
1606                        return;
1607                    }
1608                };
1609                conversation_from_session(&session_guard)
1610            };
1611
1612            let _ = crate::interactive::enqueue_pi_event(
1613                &event_tx,
1614                &task_cx,
1615                PiMsg::ConversationReset {
1616                    messages,
1617                    usage,
1618                    status: Some("Session resumed".to_string()),
1619                },
1620            )
1621            .await;
1622
1623            if let Some(manager) = extensions {
1624                let _ = manager
1625                    .dispatch_event(
1626                        ExtensionEventName::SessionSwitch,
1627                        Some(json!({
1628                            "reason": "resume",
1629                            "previousSessionFile": previous_session_file,
1630                            "targetSessionFile": path,
1631                            "sessionId": new_session_id,
1632                        })),
1633                    )
1634                    .await;
1635            }
1636        });
1637
1638        self.status_message = Some("Loading session...".to_string());
1639        None
1640    }
1641}
1642
1643const fn bool_label(value: bool) -> &'static str {
1644    if value { "on" } else { "off" }
1645}
1646
1647/// Run the interactive mode.
1648#[allow(clippy::too_many_arguments)]
1649pub async fn run_interactive(
1650    agent: Agent,
1651    session: Arc<Mutex<Session>>,
1652    config: Config,
1653    model_entry: ModelEntry,
1654    model_scope: Vec<ModelEntry>,
1655    available_models: Vec<ModelEntry>,
1656    pending_inputs: Vec<PendingInput>,
1657    save_enabled: bool,
1658    resources: ResourceLoader,
1659    resource_cli: ResourceCliOptions,
1660    extensions: Option<ExtensionManager>,
1661    cwd: PathBuf,
1662    runtime_handle: RuntimeHandle,
1663) -> anyhow::Result<()> {
1664    let should_check_for_updates = config.should_check_for_updates();
1665    let show_hardware_cursor = config.show_hardware_cursor.unwrap_or_else(|| {
1666        std::env::var("PI_HARDWARE_CURSOR")
1667            .ok()
1668            .is_some_and(|val| val == "1")
1669    });
1670    let mut stdout = std::io::stdout();
1671    if show_hardware_cursor {
1672        let _ = crossterm::execute!(stdout, cursor::Show);
1673    } else {
1674        let _ = crossterm::execute!(stdout, cursor::Hide);
1675    }
1676
1677    let (event_tx, mut event_rx) = mpsc::channel::<PiMsg>(1024);
1678    let shutdown_event_tx = event_tx.clone();
1679    let (ui_tx, ui_rx) = std::sync::mpsc::channel::<Message>();
1680
1681    let ui_bridge_cx = Cx::current().unwrap_or_else(Cx::for_request);
1682    runtime_handle.spawn(async move {
1683        while let Ok(msg) = event_rx.recv(&ui_bridge_cx).await {
1684            if matches!(msg, PiMsg::UiShutdown) {
1685                break;
1686            }
1687            let _ = ui_tx.send(Message::new(msg));
1688        }
1689    });
1690
1691    if should_check_for_updates {
1692        runtime_handle.spawn(async move {
1693            let client = crate::http::client::Client::new();
1694            let _ = crate::version_check::refresh_cache_if_stale(&client).await;
1695        });
1696    }
1697
1698    let extensions = extensions;
1699
1700    if let Some(manager) = &extensions {
1701        let (extension_ui_tx, mut extension_ui_rx) = mpsc::channel::<ExtensionUiRequest>(64);
1702        manager.set_ui_sender(extension_ui_tx);
1703
1704        let extension_event_tx = event_tx.clone();
1705        let extension_ui_cx = Cx::current().unwrap_or_else(Cx::for_request);
1706        runtime_handle.spawn(async move {
1707            while let Ok(request) = extension_ui_rx.recv(&extension_ui_cx).await {
1708                if !enqueue_pi_event(
1709                    &extension_event_tx,
1710                    &extension_ui_cx,
1711                    PiMsg::ExtensionUiRequest(request),
1712                )
1713                .await
1714                {
1715                    break;
1716                }
1717            }
1718        });
1719    }
1720
1721    let (messages, usage) = {
1722        let cx = Cx::for_request();
1723        let guard = session
1724            .lock(&cx)
1725            .await
1726            .map_err(|e| anyhow::anyhow!("Failed to lock session: {e}"))?;
1727        conversation_from_session(&guard)
1728    };
1729
1730    Program::new(PiApp::new(
1731        agent,
1732        session,
1733        config,
1734        resources,
1735        resource_cli,
1736        cwd,
1737        model_entry,
1738        model_scope,
1739        available_models,
1740        pending_inputs,
1741        event_tx,
1742        runtime_handle,
1743        save_enabled,
1744        true,
1745        extensions,
1746        None,
1747        messages,
1748        usage,
1749    ))
1750    .with_alt_screen()
1751    .with_mouse_all_motion()
1752    .with_input_receiver(ui_rx)
1753    .run()?;
1754
1755    // Tell the async bridge to exit promptly even if some background task still
1756    // holds an event sender clone after the TUI has already shut down.
1757    // Use a fresh cleanup scope so bridge teardown still runs even if the ambient
1758    // interactive context is already cancelled while exiting.
1759    let shutdown_cx = Cx::for_request();
1760    enqueue_ui_shutdown(&shutdown_event_tx, &shutdown_cx).await;
1761
1762    let _ = crossterm::execute!(std::io::stdout(), cursor::Show);
1763    println!("Goodbye!");
1764    Ok(())
1765}
1766
1767pub(crate) async fn enqueue_pi_event(event_tx: &mpsc::Sender<PiMsg>, cx: &Cx, msg: PiMsg) -> bool {
1768    event_tx.send(cx, msg).await.is_ok()
1769}
1770
1771pub(crate) async fn enqueue_pi_event_current(event_tx: &mpsc::Sender<PiMsg>, msg: PiMsg) -> bool {
1772    // UI events should always reach the user, even if the emitting background task
1773    // has been cancelled. Use a fresh request context to bypass task cancellation.
1774    let cx = Cx::for_request();
1775    enqueue_pi_event(event_tx, &cx, msg).await
1776}
1777
1778pub(crate) async fn enqueue_ui_shutdown(event_tx: &mpsc::Sender<PiMsg>, cx: &Cx) {
1779    let _ = enqueue_pi_event(event_tx, cx, PiMsg::UiShutdown).await;
1780}
1781
1782/// Custom message types for async agent events.
1783#[derive(Debug, Clone)]
1784pub enum PiMsg {
1785    /// Agent started processing.
1786    AgentStart,
1787    /// Trigger processing of the next queued input (CLI startup messages).
1788    RunPending,
1789    /// Enqueue a pending input (extensions may inject while idle).
1790    EnqueuePendingInput(PendingInput),
1791    /// Internal: shut down the async→UI message bridge (used for clean exit).
1792    UiShutdown,
1793    /// Periodic autocomplete refresh tick (background file index).
1794    AutocompleteRefresh,
1795    /// Text delta from assistant.
1796    TextDelta(String),
1797    /// Thinking delta from assistant.
1798    ThinkingDelta(String),
1799    /// Tool execution started.
1800    ToolStart { name: String, tool_id: String },
1801    /// Tool execution update (streaming output).
1802    ToolUpdate {
1803        name: String,
1804        tool_id: String,
1805        content: Vec<ContentBlock>,
1806        details: Option<Value>,
1807    },
1808    /// Tool execution ended.
1809    ToolEnd {
1810        name: String,
1811        tool_id: String,
1812        is_error: bool,
1813    },
1814    /// Agent finished with final message.
1815    AgentDone {
1816        usage: Option<Usage>,
1817        stop_reason: StopReason,
1818        error_message: Option<String>,
1819    },
1820    /// Agent error.
1821    AgentError(String),
1822    /// Credentials changed for a provider; refresh in-memory provider auth state.
1823    CredentialUpdated { provider: String },
1824    /// Non-error system message.
1825    System(String),
1826    /// System note that does not mutate agent state (safe during streaming).
1827    SystemNote(String),
1828    /// Update last user message content (input transform/redaction).
1829    UpdateLastUserMessage(String),
1830    /// Bash command result (non-agent).
1831    BashResult {
1832        display: String,
1833        content_for_agent: Option<Vec<ContentBlock>>,
1834    },
1835    /// Async OAuth device flow start
1836    OAuthDeviceFlowStarted {
1837        provider: String,
1838        device_code: String,
1839        user_code: String,
1840        verification_uri: String,
1841        expires_in: u64,
1842    },
1843    /// Replace conversation state from session (compaction/fork).
1844    ConversationReset {
1845        messages: Vec<ConversationMessage>,
1846        usage: Usage,
1847        status: Option<String>,
1848    },
1849    /// Set the editor contents (used by /tree selection of user/custom messages).
1850    SetEditorText(String),
1851    /// Open the session tree selector (async from extension hooks).
1852    OpenTree {
1853        initial_selected_id: Option<String>,
1854        label: Option<String>,
1855    },
1856    /// Reloaded skills/prompts/themes/extensions.
1857    ResourcesReloaded {
1858        resources: ResourceLoader,
1859        status: String,
1860        diagnostics: Option<String>,
1861    },
1862    /// Extension UI request (select/confirm/input/editor/custom/notify).
1863    ExtensionUiRequest(ExtensionUiRequest),
1864    /// Extension command finished execution.
1865    ExtensionCommandDone {
1866        command: String,
1867        display: String,
1868        is_error: bool,
1869    },
1870    /// OAuth callback server received the browser redirect.
1871    /// The string is the full callback URL (e.g. `http://localhost:1455/auth/callback?code=abc&state=xyz`).
1872    OAuthCallbackReceived(String),
1873}
1874
1875/// Read the current git branch from `.git/HEAD` in the given directory.
1876///
1877/// Returns `Some("branch-name")` for a normal branch,
1878/// `Some("abc1234")` (7-char short SHA) for detached HEAD,
1879/// or `None` if not in a git repo or `.git/HEAD` is unreadable.
1880fn read_git_branch(cwd: &Path) -> Option<String> {
1881    let git_head = find_git_head_path(cwd)?;
1882    let content = std::fs::read_to_string(git_head).ok()?;
1883    let content = content.trim();
1884    content.strip_prefix("ref: refs/heads/").map_or_else(
1885        || {
1886            // Detached HEAD — show short SHA
1887            (content.len() >= 7 && content.chars().all(|c| c.is_ascii_hexdigit()))
1888                .then(|| content[..7].to_string())
1889        },
1890        |ref_path| Some(ref_path.to_string()),
1891    )
1892}
1893
1894/// Return whether any ancestor of `cwd` (or `cwd` itself) contains a `.jj`
1895/// directory. Walks up the tree; no subprocess cost.
1896fn is_inside_jj_repo(cwd: &Path) -> bool {
1897    let mut current = cwd.to_path_buf();
1898    loop {
1899        if current.join(".jj").is_dir() {
1900            return true;
1901        }
1902        if !current.pop() {
1903            return false;
1904        }
1905    }
1906}
1907
1908/// Read the current jj working-copy change via `jj log`, if we are inside
1909/// a jj repo and the `jj` binary is available. Returns a short display
1910/// string like `"jj:abc12345 feat: description"`, or `None` if the probe
1911/// fails for any reason — in which case the caller should fall back to
1912/// `read_git_branch`.
1913///
1914/// We check for `.jj` on disk first so that on the vastly more common
1915/// pure-git repo we never even fork a subprocess.
1916fn read_jj_change(cwd: &Path) -> Option<String> {
1917    if !is_inside_jj_repo(cwd) {
1918        return None;
1919    }
1920
1921    let output = std::process::Command::new("jj")
1922        .args([
1923            "log",
1924            "-r",
1925            "@",
1926            "--no-graph",
1927            "--template",
1928            r#"change_id.short(8) ++ " " ++ description.first_line()"#,
1929        ])
1930        .current_dir(cwd)
1931        .output()
1932        .ok()?;
1933
1934    if !output.status.success() {
1935        return None;
1936    }
1937
1938    let line = String::from_utf8(output.stdout).ok()?;
1939    let line = line.trim();
1940    if line.is_empty() {
1941        return None;
1942    }
1943
1944    // Prefix so jj context is visually distinct from a bare git branch
1945    // name in the status bar (useful in colocated repos).
1946    Some(format!("jj:{line}"))
1947}
1948
1949/// Read VCS info for the interactive status bar: prefers jj in colocated
1950/// repos (where both `.jj` and `.git` exist) so the status bar reflects
1951/// the VCS the user is actually driving, and falls back to the git
1952/// branch name in pure-git repos. Returns `None` when neither is
1953/// detectable.
1954fn read_vcs_info(cwd: &Path) -> Option<String> {
1955    read_jj_change(cwd).or_else(|| read_git_branch(cwd))
1956}
1957
1958fn find_git_head_path(cwd: &Path) -> Option<PathBuf> {
1959    let mut current = cwd.to_path_buf();
1960    loop {
1961        let dot_git = current.join(".git");
1962        if let Some(git_head) = resolve_git_head_path(&dot_git) {
1963            return Some(git_head);
1964        }
1965        if !current.pop() {
1966            return None;
1967        }
1968    }
1969}
1970
1971fn resolve_git_head_path(dot_git: &Path) -> Option<PathBuf> {
1972    if dot_git.is_dir() {
1973        let head = dot_git.join("HEAD");
1974        return head.is_file().then_some(head);
1975    }
1976
1977    if dot_git.is_file() {
1978        let dot_git_contents = std::fs::read_to_string(dot_git).ok()?;
1979        let gitdir = dot_git_contents
1980            .trim()
1981            .strip_prefix("gitdir:")
1982            .map(str::trim)?;
1983        if gitdir.is_empty() {
1984            return None;
1985        }
1986        let resolved_gitdir = Path::new(gitdir);
1987        let resolved_gitdir = if resolved_gitdir.is_absolute() {
1988            resolved_gitdir.to_path_buf()
1989        } else {
1990            dot_git.parent()?.join(resolved_gitdir)
1991        };
1992        let head = resolved_gitdir.join("HEAD");
1993        return head.is_file().then_some(head);
1994    }
1995
1996    None
1997}
1998
1999fn build_startup_welcome_message(config: &Config) -> String {
2000    if config.quiet_startup.unwrap_or(false) {
2001        return String::new();
2002    }
2003
2004    let mut message = String::from("  Welcome to Pi!\n");
2005    message.push_str("  Type a message to begin, or /help for commands.\n");
2006
2007    let auth_path = Config::auth_path();
2008    if let Ok(auth) = crate::auth::AuthStorage::load(auth_path) {
2009        if should_show_startup_oauth_hint(&auth) {
2010            message.push('\n');
2011            message.push_str(&format_startup_oauth_hint(&auth));
2012        }
2013    }
2014
2015    message
2016}
2017
2018#[derive(Debug, Clone, PartialEq, Eq)]
2019enum StartupChangelog {
2020    Condensed { latest_version: String },
2021    Full { markdown: String },
2022}
2023
2024fn changelog_heading_matches_version(heading: &str, version: &str) -> bool {
2025    let token = heading
2026        .trim_start_matches('#')
2027        .split_whitespace()
2028        .next()
2029        .unwrap_or_default()
2030        .trim_matches(|ch| matches!(ch, '[' | ']' | '(' | ')'));
2031
2032    token == version || token == format!("v{version}")
2033}
2034
2035fn collect_startup_changelog_sections(
2036    changelog: &str,
2037    current_version: &str,
2038    last_seen_version: &str,
2039) -> Option<String> {
2040    let mut sections = Vec::new();
2041    let mut current_section = Vec::new();
2042    let mut collecting = false;
2043    let mut saw_current_version = false;
2044
2045    for line in changelog.lines() {
2046        if line.starts_with("## ") {
2047            if collecting && !current_section.is_empty() {
2048                sections.push(current_section.join("\n"));
2049                current_section.clear();
2050            }
2051
2052            if changelog_heading_matches_version(line, last_seen_version) {
2053                break;
2054            }
2055
2056            collecting =
2057                saw_current_version || changelog_heading_matches_version(line, current_version);
2058            if collecting {
2059                saw_current_version = true;
2060                current_section.push(line.to_string());
2061            }
2062            continue;
2063        }
2064
2065        if collecting {
2066            current_section.push(line.to_string());
2067        }
2068    }
2069
2070    if collecting && !current_section.is_empty() {
2071        sections.push(current_section.join("\n"));
2072    }
2073
2074    let combined = sections.join("\n\n");
2075    let trimmed = combined.trim();
2076    (!trimmed.is_empty()).then(|| trimmed.to_string())
2077}
2078
2079fn persist_last_changelog_version_with_roots(
2080    global_dir: &Path,
2081    cwd: &Path,
2082    config_override: Option<&Path>,
2083    version: &str,
2084) -> crate::error::Result<PathBuf> {
2085    let patch = json!({ "lastChangelogVersion": version });
2086    if let Some(path) = config_override {
2087        return Config::patch_settings_to_path(path, patch);
2088    }
2089
2090    Config::patch_settings_with_roots(SettingsScope::Global, global_dir, cwd, patch)
2091}
2092
2093#[allow(clippy::too_many_arguments)]
2094fn prepare_startup_changelog_with_roots(
2095    config: &mut Config,
2096    global_dir: &Path,
2097    cwd: &Path,
2098    config_override: Option<&Path>,
2099    has_existing_messages: bool,
2100    persist_version_updates: bool,
2101    current_version: &str,
2102    changelog_markdown: &str,
2103) -> Option<StartupChangelog> {
2104    if has_existing_messages {
2105        return None;
2106    }
2107
2108    let remember_version = |config: &mut Config| {
2109        if persist_version_updates {
2110            if let Err(err) = persist_last_changelog_version_with_roots(
2111                global_dir,
2112                cwd,
2113                config_override,
2114                current_version,
2115            ) {
2116                tracing::warn!("Failed to persist last changelog version: {err}");
2117            }
2118        }
2119        config.last_changelog_version = Some(current_version.to_string());
2120    };
2121
2122    let Some(last_seen_version) = config.last_changelog_version.as_deref() else {
2123        remember_version(config);
2124        return None;
2125    };
2126
2127    if last_seen_version == current_version {
2128        return None;
2129    }
2130
2131    let markdown =
2132        collect_startup_changelog_sections(changelog_markdown, current_version, last_seen_version)?;
2133    remember_version(config);
2134
2135    if config.quiet_startup.unwrap_or(false) || config.collapse_changelog.unwrap_or(false) {
2136        Some(StartupChangelog::Condensed {
2137            latest_version: current_version.to_string(),
2138        })
2139    } else {
2140        Some(StartupChangelog::Full { markdown })
2141    }
2142}
2143
2144#[cfg(test)]
2145mod startup_changelog_tests {
2146    use super::*;
2147
2148    const SAMPLE_CHANGELOG: &str = r"# Changelog
2149
2150## [Unreleased] (after v0.1.9)
2151
2152- preview-only note
2153
2154## [v0.1.9] -- 2026-03-12 -- Release
2155
2156- shipped fix
2157
2158## [v0.1.8] -- 2026-03-01 -- Release
2159
2160- previous release
2161";
2162
2163    fn tempdir() -> tempfile::TempDir {
2164        std::fs::create_dir_all(std::env::temp_dir()).expect("create temp root");
2165        tempfile::tempdir().expect("temp dir")
2166    }
2167
2168    #[test]
2169    fn collect_startup_changelog_sections_starts_at_current_release() {
2170        let markdown =
2171            collect_startup_changelog_sections(SAMPLE_CHANGELOG, "0.1.9", "0.1.8").unwrap();
2172
2173        assert!(markdown.contains("## [v0.1.9]"));
2174        assert!(markdown.contains("shipped fix"));
2175        assert!(!markdown.contains("Unreleased"));
2176        assert!(!markdown.contains("preview-only note"));
2177        assert!(!markdown.contains("v0.1.8"));
2178    }
2179
2180    #[test]
2181    fn prepare_startup_changelog_with_roots_skips_unreleased_section() {
2182        let temp = tempdir();
2183        let config_path = temp.path().join("settings.json");
2184        let global_dir = temp.path().join("global");
2185        let cwd = temp.path().join("cwd");
2186        std::fs::create_dir_all(&global_dir).expect("global dir");
2187        std::fs::create_dir_all(&cwd).expect("cwd dir");
2188
2189        let mut config = Config {
2190            last_changelog_version: Some("0.1.8".to_string()),
2191            ..Config::default()
2192        };
2193
2194        let result = prepare_startup_changelog_with_roots(
2195            &mut config,
2196            &global_dir,
2197            &cwd,
2198            Some(&config_path),
2199            false,
2200            true,
2201            "0.1.9",
2202            SAMPLE_CHANGELOG,
2203        );
2204
2205        let markdown = match result {
2206            Some(StartupChangelog::Full { markdown }) => markdown,
2207            other => {
2208                assert!(
2209                    matches!(other, Some(StartupChangelog::Full { .. })),
2210                    "expected full startup changelog, got {other:?}"
2211                );
2212                return;
2213            }
2214        };
2215        assert!(markdown.contains("## [v0.1.9]"));
2216        assert!(!markdown.contains("Unreleased"));
2217        assert_eq!(config.last_changelog_version.as_deref(), Some("0.1.9"));
2218
2219        let persisted: serde_json::Value =
2220            serde_json::from_str(&std::fs::read_to_string(&config_path).expect("settings file"))
2221                .expect("valid settings json");
2222        assert_eq!(persisted["lastChangelogVersion"], "0.1.9");
2223    }
2224}
2225
2226/// The main interactive TUI application model.
2227#[allow(clippy::struct_excessive_bools)]
2228#[derive(bubbletea::Model)]
2229pub struct PiApp {
2230    // Input state
2231    input: TextArea,
2232    history: HistoryList,
2233    input_mode: InputMode,
2234    pending_inputs: VecDeque<PendingInput>,
2235    message_queue: Arc<StdMutex<InteractiveMessageQueue>>,
2236    injected_queue: Arc<StdMutex<InjectedMessageQueue>>,
2237
2238    // Display state - viewport for scrollable conversation
2239    pub conversation_viewport: Viewport,
2240    /// When true, the viewport auto-scrolls to the bottom on new content.
2241    /// Set to false when the user manually scrolls up; re-enabled when they
2242    /// scroll back to the bottom or a new user message is submitted.
2243    follow_stream_tail: bool,
2244    spinner: SpinnerModel,
2245    agent_state: AgentState,
2246
2247    // Terminal dimensions
2248    term_width: usize,
2249    term_height: usize,
2250    editor_padding_x: usize,
2251
2252    // Conversation state
2253    messages: Vec<ConversationMessage>,
2254    current_response: String,
2255    current_thinking: String,
2256    thinking_visible: bool,
2257    tools_expanded: bool,
2258    current_tool: Option<String>,
2259    tool_progress: Option<ToolProgress>,
2260    pending_tool_output: Option<String>,
2261
2262    // Session and config
2263    session: Arc<Mutex<Session>>,
2264    config: Config,
2265    theme: Theme,
2266    styles: TuiStyles,
2267    markdown_style: GlamourStyleConfig,
2268    resources: ResourceLoader,
2269    resource_cli: ResourceCliOptions,
2270    cwd: PathBuf,
2271    model_entry: ModelEntry,
2272    model_entry_shared: Arc<StdMutex<ModelEntry>>,
2273    model_scope: Vec<ModelEntry>,
2274    available_models: Vec<ModelEntry>,
2275    model: String,
2276    agent: Arc<Mutex<Agent>>,
2277    save_enabled: bool,
2278    abort_handle: Option<AbortHandle>,
2279    bash_running: bool,
2280
2281    // Token tracking
2282    total_usage: Usage,
2283
2284    // Async channel for agent events
2285    event_tx: mpsc::Sender<PiMsg>,
2286    runtime_handle: RuntimeHandle,
2287
2288    // Extension session state
2289    extension_streaming: Arc<AtomicBool>,
2290    extension_compacting: Arc<AtomicBool>,
2291    extension_ui_queue: VecDeque<ExtensionUiRequest>,
2292    active_extension_ui: Option<ExtensionUiRequest>,
2293    extension_custom_overlay: Option<ExtensionCustomOverlay>,
2294    extension_custom_active: bool,
2295    extension_custom_key_queue: VecDeque<String>,
2296
2297    // Status message (for slash command feedback)
2298    status_message: Option<String>,
2299
2300    // Login flow state (awaiting sensitive credential input)
2301    pending_oauth: Option<PendingOAuth>,
2302
2303    // Extension system
2304    extensions: Option<ExtensionManager>,
2305
2306    // Keybindings for action dispatch
2307    keybindings: crate::keybindings::KeyBindings,
2308
2309    // Track last Ctrl+C time for double-tap quit detection
2310    last_ctrlc_time: Option<std::time::Instant>,
2311    // Track last Escape time for double-tap tree/fork
2312    last_escape_time: Option<std::time::Instant>,
2313
2314    // Autocomplete state
2315    autocomplete: AutocompleteState,
2316
2317    // Session picker overlay for /resume
2318    session_picker: Option<SessionPickerOverlay>,
2319
2320    // Settings UI overlay for /settings
2321    settings_ui: Option<SettingsUiState>,
2322
2323    // Theme picker overlay
2324    theme_picker: Option<ThemePickerOverlay>,
2325
2326    // Tree navigation UI state (for /tree command)
2327    tree_ui: Option<TreeUiState>,
2328
2329    // Capability prompt overlay (extension permission request)
2330    capability_prompt: Option<CapabilityPromptOverlay>,
2331
2332    // Branch picker overlay (Ctrl+B quick branch switching)
2333    branch_picker: Option<BranchPickerOverlay>,
2334
2335    // Model selector overlay (Ctrl+L)
2336    model_selector: Option<crate::model_selector::ModelSelectorOverlay>,
2337
2338    // Frame timing telemetry (PERF-3)
2339    frame_timing: FrameTimingStats,
2340
2341    // Memory pressure monitoring (PERF-6)
2342    memory_monitor: MemoryMonitor,
2343
2344    // Per-message render cache (PERF-1)
2345    message_render_cache: MessageRenderCache,
2346
2347    // Pre-allocated reusable buffers for view() hot path (PERF-7)
2348    render_buffers: RenderBuffers,
2349
2350    // Current VCS info for the status bar (refreshed on startup + after
2351    // each agent turn). Shows `jj:<change_id> <description>` in jj repos
2352    // and the git branch name otherwise.
2353    vcs_info: Option<String>,
2354    // Startup banner shown in an empty conversation.
2355    startup_welcome: String,
2356    // Startup changelog notice shown for first launch after an upgrade.
2357    startup_changelog: Option<StartupChangelog>,
2358
2359    // RAII guard for tmux wheel scroll override (dropped on exit/panic).
2360    #[allow(dead_code)]
2361    tmux_wheel_guard: Option<TmuxWheelGuard>,
2362}
2363
2364impl PiApp {
2365    fn initial_window_size_cmd() -> Cmd {
2366        Cmd::new(|| {
2367            let (width, height) = terminal::size().unwrap_or((80, 24));
2368            Message::new(WindowSizeMsg { width, height })
2369        })
2370    }
2371
2372    fn autocomplete_refresh_cmd() -> Option<Cmd> {
2373        if std::env::var_os("PI_TEST_MODE").is_some() {
2374            return None;
2375        }
2376        Some(Cmd::new(|| {
2377            std::thread::sleep(std::time::Duration::from_secs(30));
2378            Message::new(PiMsg::AutocompleteRefresh)
2379        }))
2380    }
2381
2382    fn startup_init_cmd(input_cmd: Option<Cmd>, pending_cmd: Option<Cmd>) -> Option<Cmd> {
2383        let startup_cmd = sequence(vec![Some(Self::initial_window_size_cmd()), pending_cmd]);
2384        batch(vec![
2385            input_cmd,
2386            startup_cmd,
2387            Self::autocomplete_refresh_cmd(),
2388        ])
2389    }
2390
2391    /// Create a new Pi application.
2392    #[allow(clippy::too_many_arguments)]
2393    #[allow(clippy::too_many_lines)]
2394    pub fn new(
2395        agent: Agent,
2396        session: Arc<Mutex<Session>>,
2397        mut config: Config,
2398        resources: ResourceLoader,
2399        resource_cli: ResourceCliOptions,
2400        cwd: PathBuf,
2401        model_entry: ModelEntry,
2402        model_scope: Vec<ModelEntry>,
2403        available_models: Vec<ModelEntry>,
2404        pending_inputs: Vec<PendingInput>,
2405        event_tx: mpsc::Sender<PiMsg>,
2406        runtime_handle: RuntimeHandle,
2407        save_enabled: bool,
2408        persist_startup_settings: bool,
2409        extensions: Option<ExtensionManager>,
2410        keybindings_override: Option<KeyBindings>,
2411        messages: Vec<ConversationMessage>,
2412        total_usage: Usage,
2413    ) -> Self {
2414        // Get terminal size
2415        let (term_width, term_height) =
2416            terminal::size().map_or((80, 24), |(w, h)| (w as usize, h as usize));
2417
2418        let theme = Theme::resolve(&config, &cwd);
2419        let styles = theme.tui_styles();
2420        let mut markdown_style = theme.glamour_style_config();
2421        markdown_style.code_block.block.margin = Some(config.markdown_code_block_indent() as usize);
2422        let editor_padding_x = config.editor_padding_x.unwrap_or(0).min(3) as usize;
2423        let autocomplete_max_visible =
2424            config.autocomplete_max_visible.unwrap_or(5).clamp(3, 20) as usize;
2425        let thinking_visible = !config.hide_thinking_block.unwrap_or(false);
2426
2427        // Configure text area for input
2428        let mut input = TextArea::new();
2429        input.placeholder = "Type a message... (/help, /exit)".to_string();
2430        input.show_line_numbers = false;
2431        input.prompt = "> ".to_string();
2432        input.set_height(3); // Start with 3 lines
2433        input.set_width(term_width.saturating_sub(5 + editor_padding_x));
2434        input.max_height = 10; // Allow expansion up to 10 lines
2435        input.focus();
2436
2437        let spinner = SpinnerModel::with_spinner(spinners::dot()).style(styles.accent.clone());
2438
2439        // Configure viewport for conversation history.
2440        // Height budget at startup (idle):
2441        // header(4) + scroll-indicator reserve(1) + input_decoration(2) + input_lines + footer(2).
2442        let chrome = 4 + 1 + 2 + 2;
2443        let viewport_height = term_height.saturating_sub(chrome + input.height());
2444        let mut conversation_viewport =
2445            Viewport::new(term_width.saturating_sub(2), viewport_height);
2446        conversation_viewport.mouse_wheel_enabled = true;
2447        conversation_viewport.mouse_wheel_delta = 1;
2448
2449        let model = format!(
2450            "{}/{}",
2451            model_entry.model.provider.as_str(),
2452            model_entry.model.id.as_str()
2453        );
2454
2455        let model_entry_shared = Arc::new(StdMutex::new(model_entry.clone()));
2456        let extension_streaming = Arc::new(AtomicBool::new(false));
2457        let extension_compacting = Arc::new(AtomicBool::new(false));
2458        let steering_mode = parse_queue_mode_or_default(config.steering_mode.as_deref());
2459        let follow_up_mode = parse_queue_mode_or_default(config.follow_up_mode.as_deref());
2460        let message_queue = Arc::new(StdMutex::new(InteractiveMessageQueue::new(
2461            steering_mode,
2462            follow_up_mode,
2463        )));
2464        let injected_queue = Arc::new(StdMutex::new(InjectedMessageQueue::new(
2465            steering_mode,
2466            follow_up_mode,
2467        )));
2468
2469        let mut agent = agent;
2470        agent.set_queue_modes(steering_mode, follow_up_mode);
2471        {
2472            let steering_queue = Arc::clone(&message_queue);
2473            let follow_up_queue = Arc::clone(&message_queue);
2474            let injected_steering_queue = Arc::clone(&injected_queue);
2475            let injected_follow_up_queue = Arc::clone(&injected_queue);
2476            let steering_fetcher = move || -> BoxFuture<'static, Vec<ModelMessage>> {
2477                let steering_queue = Arc::clone(&steering_queue);
2478                let injected_steering_queue = Arc::clone(&injected_steering_queue);
2479                Box::pin(async move {
2480                    let mut out = Vec::new();
2481                    if let Ok(mut queue) = steering_queue.lock() {
2482                        out.extend(queue.pop_steering().into_iter().map(build_user_message));
2483                    }
2484                    if let Ok(mut queue) = injected_steering_queue.lock() {
2485                        out.extend(queue.pop_steering());
2486                    }
2487                    out
2488                })
2489            };
2490            let follow_up_fetcher = move || -> BoxFuture<'static, Vec<ModelMessage>> {
2491                let follow_up_queue = Arc::clone(&follow_up_queue);
2492                let injected_follow_up_queue = Arc::clone(&injected_follow_up_queue);
2493                Box::pin(async move {
2494                    let mut out = Vec::new();
2495                    if let Ok(mut queue) = follow_up_queue.lock() {
2496                        out.extend(queue.pop_follow_up().into_iter().map(build_user_message));
2497                    }
2498                    if let Ok(mut queue) = injected_follow_up_queue.lock() {
2499                        out.extend(queue.pop_follow_up());
2500                    }
2501                    out
2502                })
2503            };
2504            agent.register_message_fetchers(
2505                Some(Arc::new(steering_fetcher)),
2506                Some(Arc::new(follow_up_fetcher)),
2507            );
2508        }
2509
2510        let keybindings = keybindings_override.unwrap_or_else(|| {
2511            // Load keybindings from user config (with defaults as fallback).
2512            let keybindings_result = KeyBindings::load_from_user_config();
2513            if keybindings_result.has_warnings() {
2514                tracing::warn!(
2515                    "Keybindings warnings: {}",
2516                    keybindings_result.format_warnings()
2517                );
2518            }
2519            keybindings_result.bindings
2520        });
2521
2522        // Initialize autocomplete with catalog from resources
2523        let mut autocomplete_catalog = AutocompleteCatalog::from_resources(&resources);
2524        if let Some(manager) = &extensions {
2525            autocomplete_catalog.extension_commands = extension_commands_for_catalog(manager);
2526        }
2527        let mut autocomplete = AutocompleteState::new(cwd.clone(), autocomplete_catalog);
2528        autocomplete.max_visible = autocomplete_max_visible;
2529        if std::env::var_os("PI_TEST_MODE").is_none() {
2530            autocomplete.provider.refresh_background();
2531        }
2532
2533        let vcs_info = read_vcs_info(&cwd);
2534        let startup_welcome = build_startup_welcome_message(&config);
2535        let config_override = Config::config_path_override_from_env(&cwd);
2536        let startup_changelog = prepare_startup_changelog_with_roots(
2537            &mut config,
2538            &Config::global_dir(),
2539            &cwd,
2540            config_override.as_deref(),
2541            !messages.is_empty(),
2542            persist_startup_settings,
2543            VERSION,
2544            include_str!("../CHANGELOG.md"),
2545        );
2546
2547        let mut app = Self {
2548            input,
2549            history: HistoryList::new(),
2550            input_mode: InputMode::SingleLine,
2551            pending_inputs: VecDeque::from(pending_inputs),
2552            message_queue,
2553            injected_queue: Arc::clone(&injected_queue),
2554            conversation_viewport,
2555            follow_stream_tail: true,
2556            spinner,
2557            agent_state: AgentState::Idle,
2558            term_width,
2559            term_height,
2560            editor_padding_x,
2561            messages,
2562            current_response: String::new(),
2563            current_thinking: String::new(),
2564            thinking_visible,
2565            tools_expanded: true,
2566            current_tool: None,
2567            tool_progress: None,
2568            pending_tool_output: None,
2569            session,
2570            config,
2571            theme,
2572            styles,
2573            markdown_style,
2574            resources,
2575            resource_cli,
2576            cwd,
2577            model_entry,
2578            model_entry_shared: model_entry_shared.clone(),
2579            model_scope,
2580            available_models,
2581            model,
2582            agent: Arc::new(Mutex::new(agent)),
2583            total_usage,
2584            event_tx,
2585            runtime_handle,
2586            extension_streaming: extension_streaming.clone(),
2587            extension_compacting: extension_compacting.clone(),
2588            extension_ui_queue: VecDeque::new(),
2589            active_extension_ui: None,
2590            extension_custom_overlay: None,
2591            extension_custom_active: false,
2592            extension_custom_key_queue: VecDeque::new(),
2593            status_message: None,
2594            save_enabled,
2595            abort_handle: None,
2596            bash_running: false,
2597            pending_oauth: None,
2598            extensions,
2599            keybindings,
2600            last_ctrlc_time: None,
2601            last_escape_time: None,
2602            autocomplete,
2603            session_picker: None,
2604            settings_ui: None,
2605            theme_picker: None,
2606            tree_ui: None,
2607            capability_prompt: None,
2608            branch_picker: None,
2609            model_selector: None,
2610            frame_timing: FrameTimingStats::new(),
2611            memory_monitor: MemoryMonitor::new_default(),
2612            message_render_cache: MessageRenderCache::new(),
2613            render_buffers: RenderBuffers::new(),
2614            vcs_info,
2615            startup_welcome,
2616            startup_changelog,
2617            tmux_wheel_guard: TmuxWheelGuard::install(),
2618        };
2619
2620        if let Some(manager) = app.extensions.clone() {
2621            let session_handle = Arc::new(InteractiveExtensionSession {
2622                session: Arc::clone(&app.session),
2623                model_entry: model_entry_shared,
2624                is_streaming: extension_streaming,
2625                is_compacting: extension_compacting,
2626                config: app.config.clone(),
2627                save_enabled: app.save_enabled,
2628            });
2629            manager.set_session(session_handle);
2630
2631            manager.set_host_actions(Arc::new(InteractiveExtensionHostActions {
2632                session: Arc::clone(&app.session),
2633                agent: Arc::clone(&app.agent),
2634                event_tx: app.event_tx.clone(),
2635                extension_streaming: Arc::clone(&app.extension_streaming),
2636                user_queue: Arc::clone(&app.message_queue),
2637                injected_queue,
2638            }));
2639        }
2640
2641        app.scroll_to_bottom();
2642
2643        // Version update check (non-blocking, cache-only on startup)
2644        if app.config.should_check_for_updates() {
2645            if let crate::version_check::VersionCheckResult::UpdateAvailable { latest } =
2646                crate::version_check::check_cached()
2647            {
2648                app.status_message = Some(format!(
2649                    "New version {latest} available (current: {})",
2650                    crate::version_check::CURRENT_VERSION
2651                ));
2652            }
2653        }
2654
2655        app
2656    }
2657
2658    #[must_use]
2659    pub fn session_handle(&self) -> Arc<Mutex<Session>> {
2660        Arc::clone(&self.session)
2661    }
2662
2663    #[must_use]
2664    pub fn agent_handle(&self) -> Arc<Mutex<Agent>> {
2665        Arc::clone(&self.agent)
2666    }
2667
2668    /// Get the current status message (for testing).
2669    pub fn status_message(&self) -> Option<&str> {
2670        self.status_message.as_deref()
2671    }
2672
2673    /// Snapshot the in-memory conversation buffer (integration test helper).
2674    pub fn conversation_messages_for_test(&self) -> &[ConversationMessage] {
2675        &self.messages
2676    }
2677
2678    /// Return the memory summary string (integration test helper).
2679    pub fn memory_summary_for_test(&self) -> String {
2680        self.memory_monitor.summary()
2681    }
2682
2683    /// Install a deterministic RSS sampler for integration tests.
2684    ///
2685    /// This replaces `/proc/self` RSS sampling with a caller-provided function
2686    /// and enables immediate sampling cadence (`sample_interval = 0`).
2687    pub fn install_memory_rss_reader_for_test(
2688        &mut self,
2689        read_fn: Box<dyn Fn() -> Option<usize> + Send>,
2690    ) {
2691        let mut monitor = MemoryMonitor::new_with_reader_fn(read_fn);
2692        monitor.sample_interval = std::time::Duration::ZERO;
2693        monitor.last_collapse = std::time::Instant::now()
2694            .checked_sub(std::time::Duration::from_secs(1))
2695            .unwrap_or_else(std::time::Instant::now);
2696        self.memory_monitor = monitor;
2697    }
2698
2699    /// Force a memory monitor sample + action pass (integration test helper).
2700    pub fn force_memory_cycle_for_test(&mut self) {
2701        self.memory_monitor.maybe_sample();
2702        self.run_memory_pressure_actions();
2703    }
2704
2705    /// Force progressive-collapse timing eligibility (integration test helper).
2706    pub fn force_memory_collapse_tick_for_test(&mut self) {
2707        self.memory_monitor.last_collapse = std::time::Instant::now()
2708            .checked_sub(std::time::Duration::from_secs(1))
2709            .unwrap_or_else(std::time::Instant::now);
2710    }
2711
2712    /// Get a reference to the model selector overlay (for testing).
2713    pub const fn model_selector(&self) -> Option<&crate::model_selector::ModelSelectorOverlay> {
2714        self.model_selector.as_ref()
2715    }
2716
2717    /// Check if the branch picker is currently open (for testing).
2718    pub const fn has_branch_picker(&self) -> bool {
2719        self.branch_picker.is_some()
2720    }
2721
2722    /// Return whether the conversation prefix cache is currently valid for
2723    /// the current message count (integration test helper for PERF-2).
2724    pub fn prefix_cache_valid_for_test(&self) -> bool {
2725        self.message_render_cache.prefix_valid(self.messages.len())
2726    }
2727
2728    /// Return the length of the cached conversation prefix
2729    /// (integration test helper for PERF-2).
2730    pub fn prefix_cache_len_for_test(&self) -> usize {
2731        self.message_render_cache.prefix_get().len()
2732    }
2733
2734    /// Return the current view capacity hint from render buffers
2735    /// (integration test helper for PERF-7).
2736    pub fn render_buffer_capacity_hint_for_test(&self) -> usize {
2737        self.render_buffers.view_capacity_hint()
2738    }
2739
2740    /// Initialize the application.
2741    fn init(&self) -> Option<Cmd> {
2742        // Start text input cursor blink.
2743        // Spinner ticks are started lazily when we transition idle -> busy.
2744        let test_mode = std::env::var_os("PI_TEST_MODE").is_some();
2745        let input_cmd = if test_mode {
2746            None
2747        } else {
2748            BubbleteaModel::init(&self.input)
2749        };
2750        let pending_cmd = if self.pending_inputs.is_empty() {
2751            None
2752        } else {
2753            Some(Cmd::new(|| Message::new(PiMsg::RunPending)))
2754        };
2755        // Ensure the initial window-size refresh lands before any queued startup work.
2756        Self::startup_init_cmd(input_cmd, pending_cmd)
2757    }
2758
2759    fn spinner_init_cmd(&self) -> Option<Cmd> {
2760        if std::env::var_os("PI_TEST_MODE").is_some() {
2761            None
2762        } else {
2763            BubbleteaModel::init(&self.spinner)
2764        }
2765    }
2766
2767    /// Handle messages (keyboard input, async events, etc.).
2768    #[allow(clippy::too_many_lines)]
2769    fn update(&mut self, msg: Message) -> Option<Cmd> {
2770        let update_start = if self.frame_timing.enabled {
2771            Some(std::time::Instant::now())
2772        } else {
2773            None
2774        };
2775        let was_busy = !matches!(self.agent_state, AgentState::Idle);
2776        let was_spinner_visible = self.spinner_visible();
2777        let result = self.update_inner(msg);
2778        let became_busy = !was_busy && !matches!(self.agent_state, AgentState::Idle);
2779        let spinner_became_visible = !was_spinner_visible && self.spinner_visible();
2780        let result = if became_busy || spinner_became_visible {
2781            batch(vec![result, self.spinner_init_cmd()])
2782        } else {
2783            result
2784        };
2785        if let Some(start) = update_start {
2786            self.frame_timing
2787                .record_update(micros_as_u64(start.elapsed().as_micros()));
2788        }
2789        result
2790    }
2791
2792    /// Inner update handler (extracted for frame timing instrumentation).
2793    #[allow(clippy::too_many_lines)]
2794    fn update_inner(&mut self, msg: Message) -> Option<Cmd> {
2795        // Memory pressure sampling + progressive collapse (PERF-6)
2796        self.memory_monitor.maybe_sample();
2797        self.run_memory_pressure_actions();
2798
2799        // Handle our custom Pi messages (take ownership to avoid per-token clone).
2800        if msg.is::<PiMsg>() {
2801            let pi_msg = msg
2802                .downcast::<PiMsg>()
2803                .expect("PiMsg downcast should succeed after type check");
2804            return self.handle_pi_message(pi_msg);
2805        }
2806
2807        if let Some(size) = msg.downcast_ref::<WindowSizeMsg>() {
2808            self.set_terminal_size(size.width as usize, size.height as usize);
2809            return None;
2810        }
2811
2812        // Handle mouse wheel events: route to overlays when open, otherwise
2813        // scroll the conversation viewport.
2814        if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
2815            if mouse.is_wheel()
2816                && (mouse.button == MouseButton::WheelUp || mouse.button == MouseButton::WheelDown)
2817            {
2818                let is_up = mouse.button == MouseButton::WheelUp;
2819                return self.handle_mouse_wheel(is_up);
2820            }
2821        }
2822
2823        // Ignore spinner ticks when no spinner row is visible so old tick
2824        // chains naturally stop and do not trigger hidden redraw churn.
2825        if msg.downcast_ref::<SpinnerTickMsg>().is_some() && !self.spinner_visible() {
2826            return None;
2827        }
2828
2829        // Handle keyboard input via keybindings layer
2830        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
2831            // Clear status message on any key press
2832            self.status_message = None;
2833            if key.key_type != KeyType::Esc {
2834                self.last_escape_time = None;
2835            }
2836
2837            if self.handle_custom_extension_key(key) {
2838                return None;
2839            }
2840
2841            // /tree modal captures all input while active.
2842            if self.tree_ui.is_some() {
2843                return self.handle_tree_ui_key(key);
2844            }
2845
2846            // Capability prompt modal captures all input while active.
2847            if self.capability_prompt.is_some() {
2848                return self.handle_capability_prompt_key(key);
2849            }
2850
2851            // Branch picker modal captures all input while active.
2852            if self.branch_picker.is_some() {
2853                return self.handle_branch_picker_key(key);
2854            }
2855
2856            // Model selector modal captures all input while active.
2857            if self.model_selector.is_some() {
2858                return self.handle_model_selector_key(key);
2859            }
2860
2861            // Theme picker modal captures all input while active.
2862            if self.theme_picker.is_some() {
2863                let mut picker = self
2864                    .theme_picker
2865                    .take()
2866                    .expect("checked theme_picker is_some");
2867                match key.key_type {
2868                    KeyType::Up => picker.select_prev(),
2869                    KeyType::Down => picker.select_next(),
2870                    KeyType::PgUp => picker.select_page_up(),
2871                    KeyType::PgDown => picker.select_page_down(),
2872                    KeyType::Runes if key.runes == ['k'] => picker.select_prev(),
2873                    KeyType::Runes if key.runes == ['j'] => picker.select_next(),
2874                    KeyType::Enter => {
2875                        if let Some(item) = picker.selected_item() {
2876                            let loaded = match item {
2877                                ThemePickerItem::BuiltIn(name) => Ok(match *name {
2878                                    "light" => Theme::light(),
2879                                    "solarized" => Theme::solarized(),
2880                                    _ => Theme::dark(),
2881                                }),
2882                                ThemePickerItem::File { path, .. } => Theme::load(path),
2883                            };
2884
2885                            match loaded {
2886                                Ok(theme) => {
2887                                    let theme_name = theme.name.clone();
2888                                    self.apply_theme(theme);
2889                                    self.config.theme = Some(theme_name.clone());
2890                                    if let Err(e) = self.persist_project_theme(&theme_name) {
2891                                        self.status_message =
2892                                            Some(format!("Failed to persist theme: {e}"));
2893                                    } else {
2894                                        self.status_message =
2895                                            Some(format!("Switched to theme: {theme_name}"));
2896                                    }
2897                                }
2898                                Err(e) => {
2899                                    self.status_message =
2900                                        Some(format!("Failed to load selected theme: {e}"));
2901                                }
2902                            }
2903                        }
2904                        self.theme_picker = None;
2905                        return None;
2906                    }
2907                    KeyType::Esc => {
2908                        self.theme_picker = None;
2909                        let mut settings = SettingsUiState::new();
2910                        settings.max_visible = overlay_max_visible(self.term_height);
2911                        self.settings_ui = Some(settings);
2912                        return None;
2913                    }
2914                    KeyType::Runes if key.runes == ['q'] => {
2915                        self.theme_picker = None;
2916                        let mut settings = SettingsUiState::new();
2917                        settings.max_visible = overlay_max_visible(self.term_height);
2918                        self.settings_ui = Some(settings);
2919                        return None;
2920                    }
2921                    _ => {}
2922                }
2923                self.theme_picker = Some(picker);
2924                return None;
2925            }
2926
2927            // /settings modal captures all input while active.
2928            if self.settings_ui.is_some() {
2929                let mut settings_ui = self
2930                    .settings_ui
2931                    .take()
2932                    .expect("checked settings_ui is_some");
2933                match key.key_type {
2934                    KeyType::Up => {
2935                        settings_ui.select_prev();
2936                        self.settings_ui = Some(settings_ui);
2937                        return None;
2938                    }
2939                    KeyType::Down => {
2940                        settings_ui.select_next();
2941                        self.settings_ui = Some(settings_ui);
2942                        return None;
2943                    }
2944                    KeyType::PgUp => {
2945                        settings_ui.select_page_up();
2946                        self.settings_ui = Some(settings_ui);
2947                        return None;
2948                    }
2949                    KeyType::PgDown => {
2950                        settings_ui.select_page_down();
2951                        self.settings_ui = Some(settings_ui);
2952                        return None;
2953                    }
2954                    KeyType::Runes if key.runes == ['k'] => {
2955                        settings_ui.select_prev();
2956                        self.settings_ui = Some(settings_ui);
2957                        return None;
2958                    }
2959                    KeyType::Runes if key.runes == ['j'] => {
2960                        settings_ui.select_next();
2961                        self.settings_ui = Some(settings_ui);
2962                        return None;
2963                    }
2964                    KeyType::Enter => {
2965                        if let Some(selected) = settings_ui.selected_entry() {
2966                            match selected {
2967                                SettingsUiEntry::Summary => {
2968                                    self.messages.push(ConversationMessage {
2969                                        role: MessageRole::System,
2970                                        content: self.format_settings_summary(),
2971                                        thinking: None,
2972                                        collapsed: false,
2973                                    });
2974                                    self.scroll_to_bottom();
2975                                    self.status_message =
2976                                        Some("Selected setting: Summary".to_string());
2977                                }
2978                                _ => {
2979                                    self.toggle_settings_entry(selected);
2980                                }
2981                            }
2982                        }
2983                        self.settings_ui = None;
2984                        return None;
2985                    }
2986                    KeyType::Esc => {
2987                        self.settings_ui = None;
2988                        self.status_message = Some("Settings cancelled".to_string());
2989                        return None;
2990                    }
2991                    KeyType::Runes if key.runes == ['q'] => {
2992                        self.settings_ui = None;
2993                        self.status_message = Some("Settings cancelled".to_string());
2994                        return None;
2995                    }
2996                    _ => {
2997                        self.settings_ui = Some(settings_ui);
2998                        return None;
2999                    }
3000                }
3001            }
3002
3003            // Handle session picker navigation when overlay is open
3004            if let Some(ref mut picker) = self.session_picker {
3005                // If in delete confirmation mode, handle y/n/Esc/Enter
3006                if picker.confirm_delete {
3007                    match key.key_type {
3008                        KeyType::Runes if key.runes == ['y'] || key.runes == ['Y'] => {
3009                            picker.confirm_delete = false;
3010                            match picker.delete_selected() {
3011                                Ok(()) => {
3012                                    if picker.all_sessions.is_empty() {
3013                                        self.session_picker = None;
3014                                        self.status_message =
3015                                            Some("No sessions found for this project".to_string());
3016                                    } else if picker.sessions.is_empty() {
3017                                        picker.status_message =
3018                                            Some("No sessions match current filter.".to_string());
3019                                    } else {
3020                                        picker.status_message =
3021                                            Some("Session deleted.".to_string());
3022                                    }
3023                                }
3024                                Err(err) => {
3025                                    picker.status_message = Some(err.to_string());
3026                                }
3027                            }
3028                            return None;
3029                        }
3030                        KeyType::Runes if key.runes == ['n'] || key.runes == ['N'] => {
3031                            // Cancel delete
3032                            picker.confirm_delete = false;
3033                            picker.status_message = None;
3034                            return None;
3035                        }
3036                        KeyType::Esc => {
3037                            // Cancel delete
3038                            picker.confirm_delete = false;
3039                            picker.status_message = None;
3040                            return None;
3041                        }
3042                        _ => {
3043                            // Ignore other keys in confirmation mode
3044                            return None;
3045                        }
3046                    }
3047                }
3048
3049                // Normal picker mode
3050                match key.key_type {
3051                    KeyType::Up => {
3052                        picker.select_prev();
3053                        return None;
3054                    }
3055                    KeyType::Down => {
3056                        picker.select_next();
3057                        return None;
3058                    }
3059                    KeyType::PgUp => {
3060                        picker.select_page_up();
3061                        return None;
3062                    }
3063                    KeyType::PgDown => {
3064                        picker.select_page_down();
3065                        return None;
3066                    }
3067                    KeyType::Runes if key.runes == ['k'] && !picker.has_query() => {
3068                        picker.select_prev();
3069                        return None;
3070                    }
3071                    KeyType::Runes if key.runes == ['j'] && !picker.has_query() => {
3072                        picker.select_next();
3073                        return None;
3074                    }
3075                    KeyType::Backspace => {
3076                        picker.pop_char();
3077                        return None;
3078                    }
3079                    KeyType::Enter => {
3080                        // Load the selected session
3081                        if let Some(session_meta) = picker.selected_session().cloned() {
3082                            self.session_picker = None;
3083                            return self.load_session_from_path(&session_meta.path);
3084                        }
3085                        return None;
3086                    }
3087                    KeyType::CtrlD => {
3088                        picker.confirm_delete = true;
3089                        picker.status_message =
3090                            Some("Delete session? Press y/n to confirm.".to_string());
3091                        return None;
3092                    }
3093                    KeyType::Esc => {
3094                        self.session_picker = None;
3095                        return None;
3096                    }
3097                    KeyType::Runes if key.runes == ['q'] && !picker.has_query() => {
3098                        self.session_picker = None;
3099                        return None;
3100                    }
3101                    KeyType::Runes => {
3102                        picker.push_chars(key.runes.iter().copied());
3103                        return None;
3104                    }
3105                    _ => {
3106                        // Ignore other keys while picker is open
3107                        return None;
3108                    }
3109                }
3110            }
3111
3112            // Handle autocomplete navigation when dropdown is open.
3113            //
3114            // IMPORTANT: Enter submits the current editor contents; Tab accepts autocomplete.
3115            if self.autocomplete.open {
3116                match key.key_type {
3117                    KeyType::Up => {
3118                        self.autocomplete.select_prev();
3119                        return None;
3120                    }
3121                    KeyType::Down => {
3122                        self.autocomplete.select_next();
3123                        return None;
3124                    }
3125                    KeyType::Tab => {
3126                        // If nothing is selected yet, select the first item
3127                        // so Tab always accepts something when the popup is open.
3128                        if self.autocomplete.selected.is_none() {
3129                            self.autocomplete.select_next();
3130                        }
3131                        // Accept the selected item
3132                        if let Some(item) = self.autocomplete.selected_item().cloned() {
3133                            self.accept_autocomplete(&item);
3134                        }
3135                        self.autocomplete.close();
3136                        return None;
3137                    }
3138                    KeyType::Enter => {
3139                        // Close autocomplete and allow Enter to submit.
3140                        self.autocomplete.close();
3141                    }
3142                    KeyType::Esc => {
3143                        self.autocomplete.close();
3144                        return None;
3145                    }
3146                    _ => {
3147                        // Close autocomplete on other keys, then process normally
3148                        self.autocomplete.close();
3149                    }
3150                }
3151            }
3152
3153            // Handle bracketed paste (drag/drop paths, etc.) before keybindings.
3154            if key.paste && self.handle_paste_event(key) {
3155                return None;
3156            }
3157
3158            // Convert KeyMsg to KeyBinding and resolve action
3159            if let Some(binding) = KeyBinding::from_bubbletea_key(key) {
3160                let candidates = self.keybindings.matching_actions(&binding);
3161                if let Some(action) = self.resolve_action(&candidates) {
3162                    // Dispatch action based on current state
3163                    if let Some(cmd) = self.handle_action(action, key) {
3164                        return Some(cmd);
3165                    }
3166                    // Action was handled but returned None (no command needed)
3167                    // Check if we should suppress forwarding to text area
3168                    if self.should_consume_action(action) {
3169                        return None;
3170                    }
3171                }
3172
3173                // Extension shortcuts: check if unhandled key matches an extension shortcut
3174                if matches!(self.agent_state, AgentState::Idle) {
3175                    let key_id = binding.to_string().to_lowercase();
3176                    if let Some(manager) = &self.extensions {
3177                        if manager.has_shortcut(&key_id) {
3178                            return self.dispatch_extension_shortcut(&key_id);
3179                        }
3180                    }
3181                }
3182            }
3183
3184            // Handle raw keys that don't map to actions but need special behavior
3185            // (e.g., text input handled by TextArea)
3186        }
3187
3188        // Forward to appropriate component based on state
3189        if matches!(self.agent_state, AgentState::Idle) {
3190            let old_height = self.input.height();
3191
3192            if let Some(key) = msg.downcast_ref::<KeyMsg>() {
3193                if key.key_type == KeyType::Space {
3194                    let mut key = key.clone();
3195                    key.key_type = KeyType::Runes;
3196                    key.runes = vec![' '];
3197
3198                    let result = BubbleteaModel::update(&mut self.input, Message::new(key));
3199
3200                    if self.input.height() != old_height {
3201                        self.refresh_conversation_viewport(self.follow_stream_tail);
3202                    }
3203
3204                    self.maybe_trigger_autocomplete();
3205                    return result;
3206                }
3207            }
3208            let result = BubbleteaModel::update(&mut self.input, msg);
3209
3210            if self.input.height() != old_height {
3211                self.refresh_conversation_viewport(self.follow_stream_tail);
3212            }
3213
3214            // After text area update, check if we should trigger autocomplete
3215            self.maybe_trigger_autocomplete();
3216
3217            result
3218        } else {
3219            // While processing, forward to spinner
3220            self.spinner.update(msg)
3221        }
3222    }
3223}
3224
3225#[cfg(test)]
3226mod tests;