Skip to main content

imp_tui/
app.rs

1use std::collections::{HashMap, HashSet};
2use std::hash::Hasher;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant};
6
7use imp_core::format_error_for_display;
8use imp_core::ui::WidgetContent;
9
10use imp_lua::loader::discover_extensions;
11use imp_lua::LuaRuntime;
12
13use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
14use imp_core::agent::{AgentCommand, AgentEvent, AgentHandle};
15use imp_core::builder::AgentBuilder;
16use imp_core::compaction::{
17    execute_compaction_with_retry, prepare_messages_for_compaction, select_compaction_strategy,
18    CompactionCapabilities, CompactionStrategy, COMPACTION_SUMMARY_PREFIX,
19    DEFAULT_KEEP_RECENT_GROUPS,
20};
21use imp_core::config::Config;
22use imp_core::personality::default_soul_markdown;
23use imp_core::session::{SessionEntry, SessionManager};
24use imp_core::Error as ImpCoreError;
25use imp_llm::auth::AuthStore;
26use imp_llm::model::{ModelMeta, ModelRegistry, ProviderRegistry};
27use imp_llm::providers::create_provider;
28use imp_llm::{
29    truncate_chars_with_suffix, Cost, Message, Model, StreamEvent, ThinkingLevel, Usage,
30};
31use ratatui::layout::{Constraint, Direction, Layout, Rect};
32use ratatui::text::Line;
33use ratatui::widgets::Clear;
34use ratatui::Frame;
35
36use crate::animation::{spinner_frame, AnimationState};
37use crate::highlight::Highlighter;
38use crate::keybindings::{self, Action};
39use crate::selection::{
40    extract_selected_text, SelectablePane, SelectionOverlay, SelectionState, TextSurface,
41};
42use crate::terminal::{ring_terminal_bell, set_window_title, InteractiveTerminal};
43use crate::theme::Theme;
44use crate::turn_tracker::TurnTracker;
45use crate::views::ask_bar::AskState;
46use crate::views::chat::{
47    build_chat_render_data, build_click_map, build_text_surface_from_lines,
48    clamped_scroll_offset_for_total_lines, DisplayMessage, MessageRole, RenderedChatView,
49};
50use crate::views::command_palette::{builtin_commands, CommandPaletteState, CommandPaletteView};
51use crate::views::editor::{EditorState, EditorView};
52use crate::views::file_finder::{collect_project_files, FileFinderState, FileFinderView};
53use crate::views::login_picker::{login_providers, LoginPickerState, LoginPickerView};
54use crate::views::model_selector::{ModelSelection, ModelSelectorState, ModelSelectorView};
55use crate::views::personality::{PersonalityScope, PersonalityState, PersonalityView};
56use crate::views::secrets_picker::{secret_providers, SecretsPickerState, SecretsPickerView};
57use crate::views::session_picker::{SessionPickerState, SessionPickerView};
58use crate::views::settings::{SettingsState, SettingsView};
59use crate::views::sidebar::{
60    build_detail_render_data, build_detail_text_surface_from_plain_lines, build_stream_lines,
61    sidebar_sub_areas, Sidebar, SidebarDetailRenderData, SidebarView,
62};
63use crate::views::startup::{
64    summarize_inline, StartupAction, StartupPanelData, StartupPanelView, StartupSection,
65};
66use crate::views::status::StatusInfo;
67use crate::views::tools::DisplayToolCall;
68use crate::views::tree::{flatten_tree, TreeView, TreeViewState};
69use crate::views::welcome::{needs_welcome, WelcomeState, WelcomeStep, WelcomeView};
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum Pane {
73    Chat,
74    SidebarList,
75    SidebarDetail,
76}
77
78#[derive(Debug)]
79pub enum UiMode {
80    Normal,
81    ModelSelector(ModelSelectorState),
82    CommandPalette(CommandPaletteState),
83    FileFinder(FileFinderState),
84    LoginPicker(LoginPickerState),
85    SecretsPicker(SecretsPickerState),
86    TreeView(TreeViewState),
87    Settings(SettingsState),
88    Personality(PersonalityState),
89    SessionPicker(SessionPickerState),
90    Welcome(WelcomeState),
91}
92
93#[derive(Debug, Clone)]
94pub enum QueuedMessage {
95    Steer(String),
96    FollowUp(String),
97}
98
99pub enum AskReply {
100    Select(tokio::sync::oneshot::Sender<Option<usize>>),
101    Input(tokio::sync::oneshot::Sender<Option<String>>),
102}
103
104#[derive(Debug)]
105enum LoginTaskExit {
106    Success(String),
107    Failed(String),
108}
109
110fn open_url(url: &str) {
111    #[cfg(target_os = "macos")]
112    {
113        let _ = std::process::Command::new("open").arg(url).spawn();
114    }
115    #[cfg(target_os = "linux")]
116    {
117        let _ = std::process::Command::new("xdg-open").arg(url).spawn();
118    }
119    #[cfg(target_os = "windows")]
120    {
121        let _ = std::process::Command::new("cmd")
122            .args(["/C", "start", url])
123            .spawn();
124    }
125}
126
127fn search_provider_docs_url(provider: &str) -> &'static str {
128    match provider {
129        "tavily" => "https://app.tavily.com/home",
130        "exa" => "https://dashboard.exa.ai/api-keys",
131        "linkup" => "https://app.linkup.so/api-keys",
132        "perplexity" => "https://www.perplexity.ai/settings/api",
133        _ => "",
134    }
135}
136
137fn prompt_text_for_secret_provider(provider: &str) -> String {
138    let docs = search_provider_docs_url(provider);
139    let mut lines = vec![format!("Configure secure credentials for {provider}")];
140    if !docs.is_empty() {
141        lines.push(String::new());
142        lines.push(format!("Get credentials at: {docs}"));
143    }
144    lines.push(String::new());
145    lines.push("First enter a comma-separated field list (default: api_key).".into());
146    lines.push("Then imp will prompt for each field value.".into());
147    lines.join("\n")
148}
149
150#[derive(Debug)]
151enum SecretsFlowState {
152    AwaitingFieldNames {
153        provider: String,
154    },
155    AwaitingFieldValues {
156        provider: String,
157        fields: Vec<String>,
158        current: usize,
159        values: HashMap<String, String>,
160    },
161}
162
163#[derive(Debug)]
164enum RuntimeSignal {
165    AgentEvent(AgentEvent),
166    AgentTaskCompleted,
167    AgentTaskFailed(String),
168    LoginTaskSucceeded(String),
169    LoginTaskFailed(String),
170    UiRequest(crate::tui_interface::UiRequest),
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174enum ScrollDirection {
175    Up,
176    Down,
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180struct DragAutoScroll {
181    pane: SelectablePane,
182    direction: ScrollDirection,
183    speed: usize,
184    column: u16,
185    row: u16,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189struct ThemeKind {
190    is_light: bool,
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194struct ChatRenderCacheKey {
195    width: u16,
196    messages_epoch: u64,
197    tick: u64,
198    chat_tool_focus: Option<usize>,
199    word_wrap: bool,
200    chat_tool_display: imp_core::config::ChatToolDisplay,
201    thinking_lines: usize,
202    show_timestamps: bool,
203    animation_level: imp_core::config::AnimationLevel,
204    activity_state: AnimationState,
205    theme: ThemeKind,
206}
207
208#[derive(Debug)]
209struct ChatRenderCache {
210    key: ChatRenderCacheKey,
211    render: crate::views::chat::ChatRenderData,
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215struct SidebarStreamCacheKey {
216    width: u16,
217    messages_epoch: u64,
218    tick: u64,
219    selected: Option<usize>,
220    word_wrap: bool,
221    tool_output: imp_core::config::ToolOutputDisplay,
222    tool_output_lines: usize,
223    animation_level: imp_core::config::AnimationLevel,
224    theme: ThemeKind,
225}
226
227#[derive(Debug)]
228struct SidebarStreamCache {
229    key: SidebarStreamCacheKey,
230    lines: Vec<Line<'static>>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234struct SidebarDetailCacheKey {
235    width: u16,
236    messages_epoch: u64,
237    selected_tool_id_hash: u64,
238    word_wrap: bool,
239    tool_output_lines: usize,
240    animation_level: imp_core::config::AnimationLevel,
241    theme: ThemeKind,
242}
243
244#[derive(Debug)]
245struct SidebarDetailCache {
246    key: SidebarDetailCacheKey,
247    render: SidebarDetailRenderData,
248}
249
250#[derive(Debug, Clone)]
251struct StartupSurfaceData {
252    panel: StartupPanelData,
253}
254
255pub struct App {
256    // Core
257    pub running: bool,
258    pub messages: Vec<DisplayMessage>,
259    pub editor: EditorState,
260    ask_editor_backup: Option<EditorState>,
261    pub cwd: PathBuf,
262
263    // Agent
264    pub agent_handle: Option<AgentHandle>,
265    agent_task: Option<tokio::task::JoinHandle<Result<(), ImpCoreError>>>,
266    pub is_streaming: bool,
267    pub message_queue: Vec<QueuedMessage>,
268
269    // Session
270    pub session: SessionManager,
271
272    // Config
273    pub config: Config,
274    pub model_name: String,
275    pub thinking_level: ThinkingLevel,
276    pub context_window: u32,
277
278    // UI state
279    pub mode: UiMode,
280    pub scroll_offset: usize,
281    pub auto_scroll: bool,
282    pub tools_expanded: bool,
283    /// Index into the flattened tool call list. `None` means inspector follows latest.
284    pub tool_focus: Option<usize>,
285    /// True once the user explicitly selects a tool; prevents new tools stealing focus.
286    pub tool_focus_pinned: bool,
287    /// True while inspector should keep live output pinned to the bottom.
288    pub sidebar_auto_follow: bool,
289
290    pub ctrl_c_count: u8,
291    pub needs_redraw: bool,
292    last_terminal_title: Option<String>,
293    pub last_esc: Option<Instant>,
294    pub tick: u64,
295    pub max_turns_override: Option<u32>,
296    completed_turns_in_run: u32,
297    suppress_completion_notification: bool,
298    pub ui_rx: Option<tokio::sync::mpsc::Receiver<crate::tui_interface::UiRequest>>,
299    pub ask_state: Option<crate::views::ask_bar::AskState>,
300    pub ask_reply: Option<AskReply>,
301    secrets_flow: Option<SecretsFlowState>,
302    login_task: Option<tokio::task::JoinHandle<LoginTaskExit>>,
303
304    // Accumulated stats
305    pub accumulated_usage: Usage,
306    pub accumulated_cost: Cost,
307    /// Last turn's input tokens — best proxy for actual current context size.
308    pub current_context_tokens: u32,
309    chat_render_epoch: u64,
310
311    // Extension state
312    pub status_items: HashMap<String, String>,
313    pub widgets: HashMap<String, WidgetContent>,
314
315    /// Lua extension runtime (for command dispatch and hot-reload).
316    pub lua_runtime: Option<Arc<Mutex<LuaRuntime>>>,
317
318    // Sidebar
319    pub sidebar: Sidebar,
320
321    /// Which pane has focus for scroll routing.
322    pub active_pane: Pane,
323    /// Sidebar list area cached from last render (for click/scroll detection).
324    pub sidebar_list_rect: Option<Rect>,
325    /// Sidebar detail area cached from last render (for click/scroll detection).
326    pub sidebar_detail_rect: Option<Rect>,
327    /// Cached selectable chat surface from last render.
328    pub chat_surface: Option<TextSurface>,
329    /// Cached selectable sidebar detail surface from last render.
330    pub sidebar_detail_surface: Option<TextSurface>,
331    /// Current app-native text selection.
332    pub selection: Option<SelectionState>,
333    /// Selection anchor while dragging with the mouse.
334    pub drag_selection: Option<SelectablePane>,
335    /// Active edge-autoscroll while dragging a selection.
336    drag_autoscroll: Option<DragAutoScroll>,
337    /// Cached chat render data reused while only scroll offset changes.
338    chat_render_cache: Option<ChatRenderCache>,
339    sidebar_stream_cache: Option<SidebarStreamCache>,
340    sidebar_detail_cache: Option<SidebarDetailCache>,
341
342    // Turn activity tracking
343    pub turn_tracker: TurnTracker,
344
345    // Display helpers
346    pub theme: Theme,
347    pub highlighter: Highlighter,
348    pub model_registry: ModelRegistry,
349}
350
351fn selected_read_file_path_from_tool(tc: Option<&DisplayToolCall>, cwd: &Path) -> Option<PathBuf> {
352    let tc = tc?;
353    if tc.name != "read" {
354        return None;
355    }
356
357    let path = tc.details.get("path")?.as_str()?.trim();
358    if path.is_empty() {
359        return None;
360    }
361
362    let path = PathBuf::from(path);
363    Some(if path.is_absolute() {
364        path
365    } else {
366        cwd.join(path)
367    })
368}
369
370fn open_path_in_editor(path: &Path) -> std::io::Result<()> {
371    let editor = std::env::var_os("VISUAL").or_else(|| std::env::var_os("EDITOR"));
372    if let Some(editor) = editor.filter(|value| !value.is_empty()) {
373        return std::process::Command::new(editor)
374            .arg(path)
375            .spawn()
376            .map(|_| ());
377    }
378
379    #[cfg(target_os = "macos")]
380    {
381        return std::process::Command::new("open")
382            .arg(path)
383            .spawn()
384            .map(|_| ());
385    }
386
387    #[cfg(not(target_os = "macos"))]
388    {
389        std::process::Command::new("xdg-open")
390            .arg(path)
391            .spawn()
392            .map(|_| ())
393    }
394}
395
396fn model_supports_provider(registry: &ModelRegistry, provider: &str, model_id: &str) -> bool {
397    if provider == "openai-codex" {
398        return imp_llm::model::builtin_openai_codex_models()
399            .iter()
400            .any(|model| model.id == model_id);
401    }
402
403    registry
404        .list_by_provider(provider)
405        .iter()
406        .any(|model| model.id == model_id)
407}
408
409fn should_use_chatgpt_provider(
410    auth_store: &AuthStore,
411    registry: &ModelRegistry,
412    meta: &ModelMeta,
413) -> bool {
414    meta.provider == "openai"
415        && auth_store.resolve_api_key_only("openai").is_err()
416        && (auth_store.get_oauth("openai").is_some()
417            || auth_store.get_oauth("openai-codex").is_some())
418        && model_supports_provider(registry, "openai-codex", &meta.id)
419}
420
421async fn resolve_provider_api_key(
422    auth_store: &mut AuthStore,
423    provider_name: &str,
424) -> Result<String, imp_llm::Error> {
425    match provider_name {
426        "openai" => auth_store.resolve_api_key_only(provider_name),
427        "openai-codex" => auth_store.resolve_chatgpt_oauth().await,
428        _ => auth_store.resolve_with_refresh(provider_name).await,
429    }
430}
431
432fn provider_logged_in(auth_store: &AuthStore, provider: &str) -> bool {
433    match provider {
434        "openai" => {
435            auth_store.get_oauth("openai").is_some()
436                || auth_store.get_oauth("openai-codex").is_some()
437                || auth_store.has_credentials("openai")
438        }
439        _ => auth_store.has_credentials(provider),
440    }
441}
442
443fn oauth_provider(provider: &str) -> bool {
444    matches!(
445        provider,
446        "anthropic" | "openai" | "openai-codex" | "kimi-code"
447    )
448}
449
450fn parse_secret_field_names(input: &str) -> Vec<String> {
451    let names: Vec<String> = input
452        .split(',')
453        .map(str::trim)
454        .filter(|name| !name.is_empty())
455        .map(|name| name.to_string())
456        .collect();
457    if names.is_empty() {
458        vec!["api_key".to_string()]
459    } else {
460        names
461    }
462}
463
464fn bump_epoch(epoch: &mut u64) {
465    *epoch = epoch.wrapping_add(1);
466}
467
468fn stable_hash<T: std::hash::Hash>(value: &T) -> u64 {
469    let mut hasher = std::collections::hash_map::DefaultHasher::new();
470    value.hash(&mut hasher);
471    hasher.finish()
472}
473
474fn model_picker_chatgpt_oauth_models(
475    registry: &ModelRegistry,
476    auth_store: &AuthStore,
477) -> Vec<ModelMeta> {
478    let has_chatgpt_oauth =
479        auth_store.get_oauth("openai").is_some() || auth_store.get_oauth("openai-codex").is_some();
480    if !has_chatgpt_oauth || auth_store.resolve_api_key_only("openai").is_ok() {
481        return Vec::new();
482    }
483
484    imp_llm::model::builtin_openai_codex_models()
485        .into_iter()
486        .filter(|model| registry.find(&model.id).is_none())
487        .map(|mut model| {
488            model.provider = "openai".into();
489            model
490        })
491        .collect()
492}
493
494fn merge_model_options_with_oauth_only_models(
495    mut models: Vec<ModelMeta>,
496    oauth_only_models: Vec<ModelMeta>,
497) -> Vec<ModelMeta> {
498    if oauth_only_models.is_empty() {
499        return models;
500    }
501
502    let insert_at = models
503        .iter()
504        .rposition(|model| model.provider == "openai")
505        .map_or(models.len(), |index| index + 1);
506    models.splice(insert_at..insert_at, oauth_only_models);
507    models
508}
509
510fn filtered_model_options(
511    registry: &ModelRegistry,
512    config: &Config,
513    auth_store: &AuthStore,
514) -> Vec<ModelMeta> {
515    let oauth_only_models = model_picker_chatgpt_oauth_models(registry, auth_store);
516
517    match &config.enabled_models {
518        Some(enabled) if !enabled.is_empty() => {
519            let available_models = merge_model_options_with_oauth_only_models(
520                registry.list().to_vec(),
521                oauth_only_models,
522            );
523
524            let available_ids: HashSet<&str> =
525                available_models.iter().map(|m| m.id.as_str()).collect();
526            let enabled_ids: HashSet<String> = enabled
527                .iter()
528                .filter_map(|name| registry.resolve_meta(name, None).map(|model| model.id))
529                .filter(|id| available_ids.contains(id.as_str()))
530                .collect();
531
532            available_models
533                .into_iter()
534                .filter(|model| enabled_ids.contains(&model.id))
535                .collect()
536        }
537        _ => {
538            let visible_models: Vec<ModelMeta> = registry
539                .list()
540                .iter()
541                .filter(|model| auth_store.has_credentials(&model.provider))
542                .cloned()
543                .collect();
544            merge_model_options_with_oauth_only_models(visible_models, oauth_only_models)
545        }
546    }
547}
548
549fn include_current_model_option(
550    mut models: Vec<ModelMeta>,
551    registry: &ModelRegistry,
552    current_model: &str,
553) -> (Vec<ModelMeta>, String) {
554    let Some(meta) = registry.resolve_meta(current_model, None) else {
555        return (models, current_model.to_string());
556    };
557
558    let canonical_id = meta.id.clone();
559    if !models.iter().any(|model| model.id == canonical_id) {
560        models.insert(0, meta);
561    }
562
563    (models, canonical_id)
564}
565
566impl App {
567    pub fn new(
568        config: Config,
569        session: SessionManager,
570        model_registry: ModelRegistry,
571        cwd: PathBuf,
572    ) -> Self {
573        let model_name = config.model.clone().unwrap_or_else(|| "sonnet".into());
574        let thinking_level = config.thinking.unwrap_or(ThinkingLevel::Medium);
575        let theme = Theme::named(config.theme.as_deref().unwrap_or("default"));
576        let context_window = model_registry
577            .resolve_meta(&model_name, None)
578            .map(|m| m.context_window)
579            .unwrap_or(200_000);
580
581        Self {
582            running: true,
583            messages: Vec::new(),
584            editor: EditorState::new(),
585            ask_editor_backup: None,
586            cwd,
587            agent_handle: None,
588            agent_task: None,
589            is_streaming: false,
590            message_queue: Vec::new(),
591            session,
592            config,
593            model_name,
594            thinking_level,
595            context_window,
596            mode: UiMode::Normal,
597            scroll_offset: 0,
598            auto_scroll: true,
599            tools_expanded: false,
600            tool_focus: None,
601            tool_focus_pinned: false,
602            sidebar_auto_follow: true,
603
604            ctrl_c_count: 0,
605            needs_redraw: true,
606            last_terminal_title: None,
607            last_esc: None,
608            tick: 0,
609            max_turns_override: None,
610            completed_turns_in_run: 0,
611            suppress_completion_notification: false,
612            ui_rx: None,
613            ask_state: None,
614            ask_reply: None,
615            secrets_flow: None,
616            login_task: None,
617            accumulated_usage: Usage::default(),
618            accumulated_cost: Cost::default(),
619            current_context_tokens: 0,
620            chat_render_epoch: 0,
621            status_items: HashMap::new(),
622            widgets: HashMap::new(),
623            lua_runtime: None,
624            sidebar: Sidebar::default(),
625            active_pane: Pane::Chat,
626            sidebar_list_rect: None,
627            sidebar_detail_rect: None,
628            chat_surface: None,
629            sidebar_detail_surface: None,
630            selection: None,
631            drag_selection: None,
632            drag_autoscroll: None,
633            chat_render_cache: None,
634            sidebar_stream_cache: None,
635            sidebar_detail_cache: None,
636            turn_tracker: TurnTracker::new(),
637            theme,
638            highlighter: Highlighter::new(),
639            model_registry,
640        }
641    }
642
643    /// Load messages from the current session branch into display messages.
644    pub fn load_session_messages(&mut self) {
645        self.messages.clear();
646        self.invalidate_chat_render_cache();
647
648        let mut branch_messages: Vec<Message> = self.session.get_active_messages();
649        imp_core::session::sanitize_messages(&mut branch_messages);
650
651        for msg in &branch_messages {
652            match msg {
653                // Attach tool results to their parent tool call display entry
654                imp_llm::Message::ToolResult(tr) => {
655                    let output_text = tr
656                        .content
657                        .iter()
658                        .filter_map(|b| match b {
659                            imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
660                            _ => None,
661                        })
662                        .collect::<Vec<_>>()
663                        .join("");
664                    let mut attached = false;
665                    for display_msg in self.messages.iter_mut().rev() {
666                        for tc in &mut display_msg.tool_calls {
667                            if tc.id == tr.tool_call_id {
668                                tc.output = Some(output_text.clone());
669                                if tc.streaming_output.is_empty() {
670                                    tc.streaming_output = output_text.clone();
671                                }
672                                tc.details = tr.details.clone();
673                                tc.is_error = tr.is_error;
674                                attached = true;
675                                break;
676                            }
677                        }
678                        if attached {
679                            break;
680                        }
681                    }
682                    // Only show as standalone if no matching tool call found
683                    if !attached {
684                        self.messages.push(DisplayMessage::from_message(msg));
685                    }
686                }
687                _ => {
688                    let mut display = DisplayMessage::from_message(msg);
689                    if matches!(msg, imp_llm::Message::User(_))
690                        && display.content.starts_with(COMPACTION_SUMMARY_PREFIX)
691                    {
692                        display.role = MessageRole::Compaction;
693                    }
694                    self.messages.push(display);
695                }
696            }
697        }
698    }
699    pub async fn run(
700        &mut self,
701        terminal: &mut InteractiveTerminal,
702    ) -> Result<(), Box<dyn std::error::Error>> {
703        self.prepare_for_interactive()?;
704        self.event_loop(terminal).await
705    }
706
707    pub fn terminal_title(&self) -> String {
708        let title = self
709            .session
710            .name()
711            .map(str::to_string)
712            .or_else(|| self.session.title(48))
713            .filter(|title| !title.trim().is_empty())
714            .unwrap_or_else(|| "chat".to_string());
715        let identity = if self.is_streaming {
716            spinner_frame(self.tick)
717        } else {
718            "imp"
719        };
720        format!("{identity} — {title}")
721    }
722
723    fn prepare_for_interactive(&mut self) -> Result<(), Box<dyn std::error::Error>> {
724        let _ = imp_core::storage::reconcile_legacy_into_global_root();
725        // Load Lua extensions (for slash commands and tool registration)
726        self.reload_lua_extensions();
727
728        // Check for first-run welcome flow
729        let config_dir = Config::user_config_dir();
730        let auth_path = imp_core::storage::global_auth_path();
731        if needs_welcome(&config_dir, &auth_path) {
732            let all_models = self.model_registry.list().to_vec();
733            self.mode = UiMode::Welcome(WelcomeState::new(&all_models));
734        }
735
736        Ok(())
737    }
738
739    async fn event_loop(
740        &mut self,
741        terminal: &mut InteractiveTerminal,
742    ) -> Result<(), Box<dyn std::error::Error>> {
743        loop {
744            self.sync_window_title();
745            // Render
746            if self.needs_redraw {
747                terminal.draw(|frame| self.render(frame))?;
748                self.needs_redraw = false;
749            }
750
751            let tick_rate = self.effective_tick_rate();
752
753            // Poll for terminal events with adaptive timeout
754            if crossterm::event::poll(tick_rate)? {
755                let event = crossterm::event::read()?;
756                match event {
757                    Event::Key(key) if key.kind == KeyEventKind::Press => {
758                        self.handle_key(key)?;
759                    }
760                    Event::Paste(text) => {
761                        self.handle_paste(text);
762                    }
763                    Event::Mouse(mouse) => {
764                        self.handle_mouse(mouse);
765                    }
766                    Event::Resize(_, _) => {
767                        self.needs_redraw = true;
768                    }
769                    _ => {}
770                }
771            }
772
773            // Drain agent events and UI requests (non-blocking)
774            self.pump_runtime_signals().await;
775
776            // Tick + periodic redraw for streaming/spinner
777            self.tick = self.tick.wrapping_add(1);
778            self.maybe_autoscroll_selection();
779            if self.is_streaming {
780                self.sync_window_title();
781                self.needs_redraw = true;
782            }
783
784            if !self.running {
785                break;
786            }
787        }
788
789        Ok(())
790    }
791
792    fn sync_window_title(&mut self) {
793        let title = self.terminal_title();
794        if self.last_terminal_title.as_deref() == Some(title.as_str()) {
795            return;
796        }
797        let _ = set_window_title(&title);
798        self.last_terminal_title = Some(title);
799    }
800
801    async fn pump_runtime_signals(&mut self) {
802        let signals = self.collect_runtime_signals().await;
803        for signal in signals {
804            self.handle_runtime_signal(signal);
805        }
806    }
807
808    async fn collect_runtime_signals(&mut self) -> Vec<RuntimeSignal> {
809        let mut signals = Vec::new();
810
811        if let Some(handle) = self.agent_handle.as_mut() {
812            while let Ok(event) = handle.event_rx.try_recv() {
813                signals.push(RuntimeSignal::AgentEvent(event));
814            }
815        }
816
817        let agent_task_finished = self
818            .agent_task
819            .as_ref()
820            .is_some_and(tokio::task::JoinHandle::is_finished);
821        if agent_task_finished {
822            if let Some(task) = self.agent_task.take() {
823                let outcome = match task.await {
824                    Ok(Ok(())) | Ok(Err(ImpCoreError::Cancelled)) => Ok(()),
825                    Ok(Err(error)) => Err(error.to_string()),
826                    Err(error) => Err(format!("Internal agent task failure: {error}")),
827                };
828
829                // Drain one more time after confirmed completion. The agent can finish with final
830                // events already queued in event_rx; if we clear the handle first,
831                // those late ToolExecutionEnd / TurnEnd / AgentEnd events are lost.
832                if let Some(handle) = self.agent_handle.as_mut() {
833                    while let Ok(event) = handle.event_rx.try_recv() {
834                        signals.push(RuntimeSignal::AgentEvent(event));
835                    }
836                }
837
838                match outcome {
839                    Ok(()) => signals.push(RuntimeSignal::AgentTaskCompleted),
840                    Err(error) => signals.push(RuntimeSignal::AgentTaskFailed(error)),
841                }
842            }
843        }
844
845        let login_task_finished = self
846            .login_task
847            .as_ref()
848            .is_some_and(tokio::task::JoinHandle::is_finished);
849        if login_task_finished {
850            if let Some(task) = self.login_task.take() {
851                match task.await {
852                    Ok(LoginTaskExit::Success(message)) => {
853                        signals.push(RuntimeSignal::LoginTaskSucceeded(message));
854                    }
855                    Ok(LoginTaskExit::Failed(message)) => {
856                        signals.push(RuntimeSignal::LoginTaskFailed(message));
857                    }
858                    Err(error) => signals.push(RuntimeSignal::LoginTaskFailed(format!(
859                        "Login task failure: {error}"
860                    ))),
861                }
862            }
863        }
864
865        if let Some(rx) = self.ui_rx.as_mut() {
866            while let Ok(req) = rx.try_recv() {
867                signals.push(RuntimeSignal::UiRequest(req));
868            }
869        }
870
871        signals
872    }
873
874    fn handle_runtime_signal(&mut self, signal: RuntimeSignal) {
875        match signal {
876            RuntimeSignal::AgentEvent(event) => self.handle_agent_event(event),
877            RuntimeSignal::AgentTaskCompleted => {
878                self.maybe_notify_agent_completion();
879                // AgentEnd handling can synchronously spawn a replacement run via a
880                // queued follow-up. Only clear the handle if no active task has
881                // taken over by the time we process completion.
882                let has_active_replacement = self
883                    .agent_task
884                    .as_ref()
885                    .is_some_and(|task| !task.is_finished());
886                if !has_active_replacement {
887                    self.agent_handle = None;
888                }
889            }
890            RuntimeSignal::AgentTaskFailed(error) => {
891                let has_active_replacement = self
892                    .agent_task
893                    .as_ref()
894                    .is_some_and(|task| !task.is_finished());
895                if !has_active_replacement {
896                    self.agent_handle = None;
897                }
898                self.present_agent_failure(error);
899            }
900            RuntimeSignal::LoginTaskSucceeded(message) => self.push_system_msg(&message),
901            RuntimeSignal::LoginTaskFailed(message) => self.push_error_msg(&message),
902            RuntimeSignal::UiRequest(req) => self.handle_ui_request(req),
903        }
904        self.needs_redraw = true;
905    }
906
907    fn present_agent_failure(&mut self, error: String) {
908        self.completed_turns_in_run = 0;
909        self.is_streaming = false;
910        if let Some(last) = self.latest_streaming_message_mut() {
911            last.is_streaming = false;
912        }
913        self.push_error_msg(&format_error_for_display(&error));
914    }
915
916    fn maybe_notify_agent_completion(&mut self) {
917        if self.is_streaming {
918            return;
919        }
920        if self.completed_turns_in_run == 0 {
921            return;
922        }
923        if self.suppress_completion_notification {
924            self.completed_turns_in_run = 0;
925            self.suppress_completion_notification = false;
926            return;
927        }
928        if !self.config.ui.notify_on_agent_complete {
929            self.completed_turns_in_run = 0;
930            return;
931        }
932
933        let _ = ring_terminal_bell();
934        self.completed_turns_in_run = 0;
935    }
936
937    fn handle_ui_request(&mut self, req: crate::tui_interface::UiRequest) {
938        use crate::tui_interface::UiRequest;
939        use crate::views::ask_bar::{AskOption, AskState};
940
941        match req {
942            UiRequest::Select {
943                title,
944                context,
945                options,
946                reply,
947            } => {
948                let ask_options: Vec<AskOption> = options
949                    .into_iter()
950                    .map(|o| AskOption {
951                        label: o.label,
952                        description: o.description,
953                        checked: false,
954                    })
955                    .collect();
956                self.begin_ask(
957                    AskState::with_placeholder(
958                        title,
959                        context,
960                        ask_options,
961                        false,
962                        "type to filter or answer freely…".into(),
963                    ),
964                    AskReply::Select(reply),
965                );
966            }
967            UiRequest::Input {
968                title,
969                context,
970                placeholder,
971                reply,
972            } => {
973                self.begin_ask(
974                    AskState::with_placeholder(title, context, vec![], false, placeholder),
975                    AskReply::Input(reply),
976                );
977            }
978            UiRequest::Confirm {
979                title,
980                message,
981                reply,
982            } => {
983                let options = vec![
984                    AskOption {
985                        label: "Yes".into(),
986                        description: None,
987                        checked: false,
988                    },
989                    AskOption {
990                        label: "No".into(),
991                        description: None,
992                        checked: false,
993                    },
994                ];
995                let (bool_tx, bool_rx) = tokio::sync::oneshot::channel();
996                self.begin_ask(
997                    AskState::with_placeholder(title, message, options, false, String::new()),
998                    AskReply::Select(bool_tx),
999                );
1000                let confirm_reply = reply;
1001                tokio::spawn(async move {
1002                    let result = bool_rx.await.ok().flatten();
1003                    let _ = confirm_reply.send(result.map(|idx| idx == 0));
1004                });
1005            }
1006            UiRequest::Notify { message, level } => match level {
1007                imp_core::ui::NotifyLevel::Error => self.push_error_msg(&message),
1008                imp_core::ui::NotifyLevel::Warning => self.push_warning_msg(&message),
1009                imp_core::ui::NotifyLevel::Info => self.push_system_msg(&message),
1010            },
1011            UiRequest::SetStatus { key, text } => {
1012                if let Some(t) = text {
1013                    self.status_items.insert(key, t);
1014                } else {
1015                    self.status_items.remove(&key);
1016                }
1017            }
1018            UiRequest::SetWidget { key, content } => {
1019                if let Some(content) = content {
1020                    self.widgets.insert(key, content);
1021                } else {
1022                    self.widgets.remove(&key);
1023                }
1024            }
1025            UiRequest::Custom { reply, .. } => {
1026                let _ = reply.send(None);
1027            }
1028        }
1029    }
1030
1031    fn begin_ask(&mut self, mut state: AskState, reply: AskReply) {
1032        if self.ask_state.is_none() {
1033            self.ask_editor_backup = Some(self.editor.clone());
1034            self.editor.clear();
1035        }
1036        state.sync_from_editor(self.editor.content(), self.editor.cursor);
1037        self.ask_state = Some(state);
1038        self.ask_reply = Some(reply);
1039    }
1040
1041    fn sync_ask_from_editor(&mut self) {
1042        if let Some(state) = self.ask_state.as_mut() {
1043            state.sync_from_editor(self.editor.content(), self.editor.cursor);
1044        }
1045    }
1046
1047    fn restore_editor_after_ask(&mut self) {
1048        if let Some(saved) = self.ask_editor_backup.take() {
1049            self.editor = saved;
1050        } else {
1051            self.editor.clear();
1052        }
1053    }
1054
1055    // ── Rendering ───────────────────────────────────────────────
1056
1057    fn current_activity_state(&self) -> AnimationState {
1058        let active_tools = self
1059            .messages
1060            .iter()
1061            .flat_map(|m| m.tool_calls.iter())
1062            .filter(|tc| tc.output.is_none() && !tc.is_error)
1063            .count() as u32;
1064
1065        let latest_streaming = self.messages.iter().rev().find(|m| m.is_streaming);
1066        let has_visible_content = latest_streaming
1067            .map(|m| !m.content.trim().is_empty())
1068            .unwrap_or(false);
1069        let has_tools_in_turn = latest_streaming
1070            .map(|m| !m.tool_calls.is_empty())
1071            .unwrap_or(active_tools > 0);
1072
1073        AnimationState::from_streaming(
1074            self.is_streaming,
1075            has_visible_content,
1076            has_tools_in_turn,
1077            active_tools,
1078            !self.message_queue.is_empty(),
1079        )
1080    }
1081
1082    fn theme_kind(&self) -> ThemeKind {
1083        ThemeKind {
1084            is_light: self.theme.bg == Theme::light().bg,
1085        }
1086    }
1087
1088    fn effective_tick_rate(&self) -> Duration {
1089        if self.is_streaming || self.drag_autoscroll.is_some() {
1090            Duration::from_millis(16)
1091        } else {
1092            Duration::from_millis(100)
1093        }
1094    }
1095
1096    fn chat_render_cache_key(
1097        &self,
1098        width: u16,
1099        chat_tool_focus: Option<usize>,
1100        chat_tool_display: imp_core::config::ChatToolDisplay,
1101        activity_state: AnimationState,
1102    ) -> ChatRenderCacheKey {
1103        ChatRenderCacheKey {
1104            width,
1105            messages_epoch: self.chat_render_epoch,
1106            tick: self.tick,
1107            chat_tool_focus,
1108            word_wrap: self.config.ui.word_wrap,
1109            chat_tool_display,
1110            thinking_lines: self.config.ui.thinking_lines,
1111            show_timestamps: self.config.ui.show_timestamps,
1112            animation_level: self.config.ui.animations,
1113            activity_state,
1114            theme: self.theme_kind(),
1115        }
1116    }
1117
1118    fn cached_chat_render(
1119        &mut self,
1120        width: u16,
1121        chat_tool_focus: Option<usize>,
1122        chat_tool_display: imp_core::config::ChatToolDisplay,
1123        activity_state: AnimationState,
1124    ) -> &crate::views::chat::ChatRenderData {
1125        let key =
1126            self.chat_render_cache_key(width, chat_tool_focus, chat_tool_display, activity_state);
1127        let cache_hit = self
1128            .chat_render_cache
1129            .as_ref()
1130            .is_some_and(|cache| cache.key == key);
1131        if !cache_hit {
1132            let render = build_chat_render_data(
1133                &self.messages,
1134                &self.theme,
1135                &self.highlighter,
1136                width as usize,
1137                self.tick,
1138                chat_tool_focus,
1139                self.config.ui.word_wrap,
1140                chat_tool_display,
1141                self.config.ui.thinking_lines,
1142                self.config.ui.show_timestamps,
1143                self.config.ui.animations,
1144                activity_state,
1145            );
1146            self.chat_render_cache = Some(ChatRenderCache { key, render });
1147        }
1148
1149        &self
1150            .chat_render_cache
1151            .as_ref()
1152            .expect("chat render cache set")
1153            .render
1154    }
1155
1156    fn invalidate_chat_render_cache(&mut self) {
1157        self.chat_render_cache = None;
1158        bump_epoch(&mut self.chat_render_epoch);
1159        self.sidebar_stream_cache = None;
1160        self.sidebar_detail_cache = None;
1161    }
1162
1163    fn sidebar_stream_cache_key(&self, width: u16) -> SidebarStreamCacheKey {
1164        SidebarStreamCacheKey {
1165            width,
1166            messages_epoch: self.chat_render_epoch,
1167            tick: self.tick,
1168            selected: self.tool_focus,
1169            word_wrap: self.config.ui.word_wrap,
1170            tool_output: self.config.ui.tool_output,
1171            tool_output_lines: self.config.ui.tool_output_lines,
1172            animation_level: self.config.ui.animations,
1173            theme: self.theme_kind(),
1174        }
1175    }
1176
1177    fn cached_sidebar_stream_lines(&mut self, width: u16) -> &Vec<Line<'static>> {
1178        let key = self.sidebar_stream_cache_key(width);
1179        let cache_hit = self
1180            .sidebar_stream_cache
1181            .as_ref()
1182            .is_some_and(|cache| cache.key == key);
1183        if !cache_hit {
1184            let all_tool_calls: Vec<&DisplayToolCall> = self
1185                .messages
1186                .iter()
1187                .flat_map(|m| m.tool_calls.iter())
1188                .collect();
1189            let lines = build_stream_lines(
1190                &all_tool_calls,
1191                self.tool_focus,
1192                &self.theme,
1193                &self.highlighter,
1194                self.tick,
1195                &self.config.ui,
1196                self.config.ui.animations,
1197                width as usize,
1198            );
1199            self.sidebar_stream_cache = Some(SidebarStreamCache { key, lines });
1200        }
1201        &self
1202            .sidebar_stream_cache
1203            .as_ref()
1204            .expect("sidebar stream cache set")
1205            .lines
1206    }
1207
1208    fn sidebar_detail_cache_key(
1209        &self,
1210        width: u16,
1211        selected_tc: Option<&DisplayToolCall>,
1212    ) -> SidebarDetailCacheKey {
1213        SidebarDetailCacheKey {
1214            width,
1215            messages_epoch: self.chat_render_epoch,
1216            selected_tool_id_hash: stable_hash(&selected_tc.map(|tc| &tc.id)),
1217            word_wrap: self.config.ui.word_wrap,
1218            tool_output_lines: self.config.ui.tool_output_lines,
1219            animation_level: self.config.ui.animations,
1220            theme: self.theme_kind(),
1221        }
1222    }
1223
1224    fn selected_tool_call(&self) -> Option<DisplayToolCall> {
1225        let index = match self.tool_focus {
1226            Some(index) => index,
1227            None if self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Inspector => {
1228                self.total_tool_calls().checked_sub(1)?
1229            }
1230            None => return None,
1231        };
1232
1233        self.messages
1234            .iter()
1235            .flat_map(|message| message.tool_calls.iter())
1236            .nth(index)
1237            .cloned()
1238    }
1239
1240    fn cached_sidebar_detail_render(
1241        &mut self,
1242        width: u16,
1243        selected_tc: Option<&DisplayToolCall>,
1244    ) -> &SidebarDetailRenderData {
1245        let key = self.sidebar_detail_cache_key(width, selected_tc);
1246        let cache_hit = self
1247            .sidebar_detail_cache
1248            .as_ref()
1249            .is_some_and(|cache| cache.key == key);
1250        if !cache_hit {
1251            let render = build_detail_render_data(
1252                selected_tc,
1253                &self.config.ui,
1254                &self.highlighter,
1255                &self.theme,
1256                width as usize,
1257            );
1258            self.sidebar_detail_cache = Some(SidebarDetailCache { key, render });
1259        }
1260        &self
1261            .sidebar_detail_cache
1262            .as_ref()
1263            .expect("sidebar detail cache set")
1264            .render
1265    }
1266
1267    fn build_startup_surface(&self) -> StartupSurfaceData {
1268        let user_config_dir = imp_core::config::Config::user_config_dir();
1269        let skills = imp_core::resources::discover_skills(&self.cwd, &user_config_dir);
1270        let lua_extensions = discover_extensions(&user_config_dir, Some(&self.cwd));
1271        let repo_label = self
1272            .cwd
1273            .file_name()
1274            .and_then(|name| name.to_str())
1275            .filter(|name| !name.trim().is_empty())
1276            .unwrap_or("this project")
1277            .to_string();
1278
1279        let (command_lines, lua_extension_summary) = match &self.lua_runtime {
1280            Some(runtime) => match runtime.lock() {
1281                Ok(rt) => {
1282                    let mut commands = rt.command_names();
1283                    commands.sort();
1284                    (
1285                        commands
1286                            .into_iter()
1287                            .map(|name| format!("• /{name}"))
1288                            .collect::<Vec<_>>(),
1289                        summarize_inline(
1290                            lua_extensions.iter().map(|ext| ext.name.clone()).collect(),
1291                            3,
1292                        ),
1293                    )
1294                }
1295                Err(_) => (
1296                    vec!["• unavailable (runtime lock error)".to_string()],
1297                    "unavailable (runtime lock error)".to_string(),
1298                ),
1299            },
1300            None => (
1301                vec!["• none loaded".to_string()],
1302                summarize_inline(
1303                    lua_extensions.iter().map(|ext| ext.name.clone()).collect(),
1304                    3,
1305                ),
1306            ),
1307        };
1308
1309        let auth_path = imp_core::storage::global_auth_path();
1310        let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
1311        let provider_meta = self.current_model_meta_for_persistence();
1312        let provider_id = provider_meta
1313            .as_ref()
1314            .map(|meta| meta.provider.as_str())
1315            .unwrap_or("unknown");
1316        let provider_auth = if auth_store.has_credentials(provider_id) {
1317            "ready"
1318        } else {
1319            "needs auth"
1320        };
1321        let web_summary = self
1322            .config
1323            .web
1324            .search_provider
1325            .map(|provider| {
1326                let status = if auth_store.has_credentials(provider.name()) {
1327                    "ready"
1328                } else {
1329                    "needs key"
1330                };
1331                format!("{} ({status})", provider.name())
1332            })
1333            .unwrap_or_else(|| "disabled".to_string());
1334        let mode = format!("{:?}", self.config.mode).to_lowercase();
1335        let session_name = self
1336            .session
1337            .name()
1338            .map(str::to_string)
1339            .or_else(|| self.session.title(48))
1340            .filter(|name| !name.trim().is_empty())
1341            .unwrap_or_else(|| "new chat".to_string());
1342        let session_lines = vec![
1343            format!("• project: {repo_label}"),
1344            format!("• session: {session_name}"),
1345            format!("• model: {}", self.model_name),
1346            format!("• provider: {provider_id} ({provider_auth})"),
1347            format!("• thinking: {:?}", self.thinking_level),
1348            format!("• web: {web_summary}"),
1349        ];
1350
1351        let visible_prompt_tools = {
1352            let mut registry = imp_core::tools::ToolRegistry::new();
1353            imp_core::builder::register_native_tools(&mut registry);
1354            let mut names = registry
1355                .definitions_for_mode(&self.config.mode)
1356                .into_iter()
1357                .map(|def| def.name)
1358                .collect::<Vec<_>>();
1359            names.sort();
1360            names
1361        };
1362
1363        let actions = vec![
1364            StartupAction {
1365                trigger: "type".to_string(),
1366                label: "start".to_string(),
1367                description: "question, goal, sketch, or task".to_string(),
1368            },
1369            StartupAction {
1370                trigger: "/resume".to_string(),
1371                label: "sessions".to_string(),
1372                description: "browse and search saved work".to_string(),
1373            },
1374            StartupAction {
1375                trigger: "/settings".to_string(),
1376                label: "runtime".to_string(),
1377                description: format!("{mode}; thinking {:?}", self.thinking_level),
1378            },
1379            StartupAction {
1380                trigger: "Ctrl+L".to_string(),
1381                label: "model".to_string(),
1382                description: format!("{}", self.model_name),
1383            },
1384        ];
1385
1386        let tool_lines = visible_prompt_tools
1387            .iter()
1388            .map(|name| format!("• {name}"))
1389            .collect::<Vec<_>>();
1390
1391        let skill_lines = if skills.is_empty() {
1392            vec!["• none discovered".to_string()]
1393        } else {
1394            let mut lines = skills
1395                .iter()
1396                .take(8)
1397                .map(|skill| format!("• {}", skill.name))
1398                .collect::<Vec<_>>();
1399            let hidden = skills.len().saturating_sub(lines.len());
1400            if hidden > 0 {
1401                lines.push(format!("… +{hidden} more"));
1402            }
1403            lines
1404        };
1405
1406        let command_names = command_lines
1407            .iter()
1408            .filter_map(|line| line.trim().strip_prefix('•'))
1409            .map(|line| line.trim().to_string())
1410            .collect::<Vec<_>>();
1411        let extension_lines = vec![
1412            format!("• lua: {lua_extension_summary}"),
1413            format!("• lua commands: {}", summarize_inline(command_names, 5)),
1414            "• shell: /new, /model, /resume, /settings, /personality, /setup".to_string(),
1415            format!("• mode: {mode}"),
1416        ];
1417
1418        let sections = vec![
1419            StartupSection {
1420                title: "session".to_string(),
1421                lines: session_lines,
1422            },
1423            StartupSection {
1424                title: "tools".to_string(),
1425                lines: tool_lines,
1426            },
1427            StartupSection {
1428                title: "skills".to_string(),
1429                lines: skill_lines,
1430            },
1431            StartupSection {
1432                title: "extensions".to_string(),
1433                lines: extension_lines,
1434            },
1435        ];
1436
1437        StartupSurfaceData {
1438            panel: StartupPanelData { actions, sections },
1439        }
1440    }
1441
1442    fn render(&mut self, frame: &mut Frame) {
1443        let area = frame.area();
1444        frame.render_widget(Clear, area);
1445
1446        // Editor/prompt height: while asking, the prompt box becomes the ask box.
1447        // Otherwise it grows to fit wrapped prompt text while preserving at least
1448        // 3 lines for the chat area.
1449        let editor_inner_width = area.width.saturating_sub(2).max(1);
1450        let desired_editor_height = if let Some(state) = self.ask_state.as_ref() {
1451            state.prompt_height(editor_inner_width)
1452        } else {
1453            self.editor
1454                .visual_line_count_with_summary(editor_inner_width, true) as u16
1455                + 2
1456        };
1457        let max_editor_height = area.height.saturating_sub(3).max(3);
1458        let editor_height = desired_editor_height.clamp(3, max_editor_height);
1459
1460        let constraints = vec![
1461            Constraint::Min(3),                // messages area
1462            Constraint::Length(editor_height), // editor / ask prompt
1463        ];
1464
1465        let chunks = Layout::default()
1466            .direction(Direction::Vertical)
1467            .constraints(constraints)
1468            .split(area);
1469
1470        let (chat_area, editor_area) = (chunks[0], chunks[1]);
1471
1472        // Split chat area for sidebar when open
1473        let (chat_area, sidebar_area) = if self.sidebar.open && chat_area.width >= 60 {
1474            let min_sidebar = 30u16;
1475            let pct = self.config.ui.sidebar_width.clamp(20, 80);
1476            let sidebar_w = (chat_area.width * pct / 100)
1477                .max(min_sidebar)
1478                .min(chat_area.width.saturating_sub(30));
1479            let chat_w = chat_area.width.saturating_sub(sidebar_w);
1480            let chat_rect = Rect {
1481                width: chat_w,
1482                ..chat_area
1483            };
1484            let sidebar_rect = Rect {
1485                x: chat_area.x + chat_w,
1486                width: sidebar_w,
1487                ..chat_area
1488            };
1489            (chat_rect, Some(sidebar_rect))
1490        } else {
1491            (chat_area, None)
1492        };
1493        let _ = self.theme_kind();
1494
1495        // Messages
1496        let chat_tool_display = self.config.ui.effective_chat_tool_display();
1497        let chat_tool_focus = if self.active_pane == Pane::Chat {
1498            self.tool_focus
1499        } else {
1500            None
1501        };
1502        let activity_state = self.current_activity_state();
1503        let total_chat_lines = {
1504            let chat_render = self.cached_chat_render(
1505                chat_area.width,
1506                chat_tool_focus,
1507                chat_tool_display,
1508                activity_state,
1509            );
1510            chat_render.lines.len()
1511        };
1512        self.scroll_offset =
1513            clamped_scroll_offset_for_total_lines(total_chat_lines, chat_area, self.scroll_offset);
1514        if self.scroll_offset == 0 {
1515            self.auto_scroll = true;
1516        }
1517
1518        let chat_lines = {
1519            self.cached_chat_render(
1520                chat_area.width,
1521                chat_tool_focus,
1522                chat_tool_display,
1523                activity_state,
1524            )
1525            .lines
1526            .clone()
1527        };
1528
1529        if matches!(self.mode, UiMode::Normal) && self.messages.is_empty() {
1530            let startup = self.build_startup_surface();
1531            frame.render_widget(
1532                StartupPanelView::new(&startup.panel, &self.theme),
1533                chat_area,
1534            );
1535            self.chat_surface = None;
1536        } else {
1537            let chat = RenderedChatView::new(&chat_lines).scroll(self.scroll_offset);
1538            frame.render_widget(chat, chat_area);
1539
1540            self.chat_surface = Some(build_text_surface_from_lines(
1541                &chat_lines,
1542                chat_area,
1543                self.scroll_offset,
1544            ));
1545        }
1546
1547        // Sidebar
1548        if let Some(sidebar_area) = sidebar_area {
1549            let tc_count = self.total_tool_calls();
1550            let sub = sidebar_sub_areas(sidebar_area, tc_count, self.config.ui.sidebar_style);
1551            let stream_lines =
1552                if self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Stream {
1553                    Some(self.cached_sidebar_stream_lines(sub.0.width).clone())
1554                } else {
1555                    None
1556                };
1557            let selected_index = self.tool_focus.or_else(|| {
1558                (self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Inspector)
1559                    .then(|| self.total_tool_calls().checked_sub(1))
1560                    .flatten()
1561            });
1562            let detail_render = if matches!(
1563                self.config.ui.sidebar_style,
1564                imp_core::config::SidebarStyle::Split | imp_core::config::SidebarStyle::Inspector
1565            ) {
1566                let selected_tc_owned = self.selected_tool_call();
1567                Some(
1568                    self.cached_sidebar_detail_render(sub.1.width, selected_tc_owned.as_ref())
1569                        .clone(),
1570                )
1571            } else {
1572                None
1573            };
1574
1575            let all_tool_calls: Vec<&DisplayToolCall> = self
1576                .messages
1577                .iter()
1578                .flat_map(|m| m.tool_calls.iter())
1579                .collect();
1580            let mut view = SidebarView::new(
1581                all_tool_calls,
1582                selected_index,
1583                &self.theme,
1584                &self.highlighter,
1585                self.tick,
1586                self.sidebar.list_scroll,
1587                self.sidebar.detail_scroll,
1588                &self.config.ui,
1589            );
1590
1591            match self.config.ui.sidebar_style {
1592                imp_core::config::SidebarStyle::Inspector => {
1593                    let detail_lines = detail_render.as_ref().expect("detail cache lines");
1594                    view = view.precomputed_detail_lines(&detail_lines.lines);
1595                    frame.render_widget(view, sidebar_area);
1596                }
1597                imp_core::config::SidebarStyle::Stream => {
1598                    let stream_lines = stream_lines.expect("stream cache lines");
1599                    view = view.precomputed_stream_lines(&stream_lines);
1600                    frame.render_widget(view, sidebar_area);
1601                }
1602                imp_core::config::SidebarStyle::Split => {
1603                    let detail_lines = detail_render.as_ref().expect("detail cache lines");
1604                    view = view.precomputed_detail_lines(&detail_lines.lines);
1605                    frame.render_widget(view, sidebar_area);
1606                }
1607            }
1608
1609            self.sidebar_list_rect = Some(sub.0);
1610            self.sidebar_detail_rect = Some(sub.1);
1611            self.sidebar.list_height = sub.0.height;
1612            let detail_plain_lines = detail_render
1613                .as_ref()
1614                .map(|render| render.plain_lines.clone())
1615                .unwrap_or_default();
1616            self.sidebar_detail_surface = Some(build_detail_text_surface_from_plain_lines(
1617                &detail_plain_lines,
1618                sub.1,
1619                self.sidebar.detail_scroll,
1620            ));
1621        } else {
1622            self.sidebar_list_rect = None;
1623            self.sidebar_detail_rect = None;
1624            self.sidebar_detail_surface = None;
1625        }
1626
1627        // Prompt area: reuse the normal editor box for asks.
1628        if let Some(ref state) = self.ask_state {
1629            use crate::views::ask_bar::AskBar;
1630            frame.render_widget(AskBar::new(state, &self.theme), editor_area);
1631        } else {
1632            let status_info = self.build_status_info();
1633            let editor = EditorView::new(&self.editor, &self.theme, self.thinking_level)
1634                .summarize_paste(true)
1635                .model(&self.model_name)
1636                .identity(&status_info.cwd, &status_info.session_name)
1637                .turn_elapsed(status_info.turn_elapsed)
1638                .extension_items(&status_info.extension_items, status_info.peek)
1639                .streaming(self.is_streaming)
1640                .queued(!self.message_queue.is_empty())
1641                .context_usage(
1642                    self.current_context_tokens,
1643                    self.context_window,
1644                    self.config.ui.show_context_usage,
1645                )
1646                .tick(self.tick)
1647                .animation_level(self.config.ui.animations)
1648                .activity_state(activity_state);
1649            frame.render_widget(editor, editor_area);
1650        }
1651
1652        frame.render_widget(
1653            SelectionOverlay::new(
1654                &self.theme,
1655                self.selection.as_ref(),
1656                self.chat_surface.as_ref(),
1657                self.sidebar_detail_surface.as_ref(),
1658            ),
1659            area,
1660        );
1661
1662        // Pre-render: clamp session picker scroll so selected item is visible
1663        if let UiMode::SessionPicker(ref mut sp) = self.mode {
1664            let overlay_area = centered_rect(75, 70, area);
1665            let inner_h = overlay_area.height.saturating_sub(2) as usize;
1666            let visible_rows = (inner_h / 3).max(1);
1667            sp.clamp_scroll(visible_rows);
1668        }
1669
1670        // Render overlays
1671        match &self.mode {
1672            UiMode::Normal => {}
1673            UiMode::ModelSelector(state) => {
1674                let overlay_area = centered_rect(60, 70, area);
1675                let view = ModelSelectorView::new(state, &self.theme);
1676                frame.render_widget(view, overlay_area);
1677            }
1678            UiMode::CommandPalette(state) => {
1679                let palette_area = command_dropdown_area(editor_area, 12);
1680                let view = CommandPaletteView::new(state, &self.theme);
1681                frame.render_widget(view, palette_area);
1682            }
1683            UiMode::FileFinder(state) => {
1684                let finder_area = command_dropdown_area(editor_area, 12);
1685                let view = FileFinderView::new(state, &self.theme);
1686                frame.render_widget(view, finder_area);
1687            }
1688            UiMode::LoginPicker(state) => {
1689                let overlay_area = centered_rect(60, 40, area);
1690                let view = LoginPickerView::new(state, &self.theme);
1691                frame.render_widget(view, overlay_area);
1692            }
1693            UiMode::SecretsPicker(state) => {
1694                let overlay_area = centered_rect(70, 50, area);
1695                let view = SecretsPickerView::new(state, &self.theme);
1696                frame.render_widget(view, overlay_area);
1697            }
1698            UiMode::TreeView(state) => {
1699                let tree_area = centered_rect(80, 80, area);
1700                let view = TreeView::new(state, &self.theme);
1701                frame.render_widget(view, tree_area);
1702            }
1703            UiMode::Settings(state) => {
1704                let overlay_area = centered_rect(80, 90, area);
1705                let view = SettingsView::new(state, &self.theme);
1706                frame.render_widget(view, overlay_area);
1707            }
1708            UiMode::Personality(state) => {
1709                let overlay_area = centered_rect(80, 80, area);
1710                let view = PersonalityView::new(state, &self.theme);
1711                frame.render_widget(view, overlay_area);
1712            }
1713            UiMode::SessionPicker(state) => {
1714                let overlay_area = centered_rect(75, 70, area);
1715                let view = SessionPickerView::new(state, &self.theme);
1716                frame.render_widget(view, overlay_area);
1717            }
1718            UiMode::Welcome(state) => {
1719                let overlay_area = centered_rect(70, 80, area);
1720                let view = WelcomeView::new(state, &self.theme);
1721                frame.render_widget(view, overlay_area);
1722            }
1723        }
1724
1725        // Set cursor position (only in normal mode)
1726        if matches!(self.mode, UiMode::Normal) {
1727            let (cx, cy) = if let Some(state) = self.ask_state.as_ref() {
1728                state.cursor_screen_position(editor_area)
1729            } else {
1730                self.editor.cursor_screen_position(editor_area)
1731            };
1732            frame.set_cursor_position((cx, cy));
1733        }
1734    }
1735
1736    fn build_status_info(&self) -> StatusInfo {
1737        let cwd = self.cwd.to_string_lossy().to_string();
1738        let session_name = self
1739            .session
1740            .name()
1741            .map(str::to_string)
1742            .or_else(|| self.session.title(48))
1743            .unwrap_or_default();
1744
1745        let total_input = self.accumulated_usage.input_tokens;
1746        let total_output = self.accumulated_usage.output_tokens;
1747        let current_context_tokens = self.current_context_tokens;
1748        // Use last turn's input_tokens as the actual context size rather than
1749        // accumulating across turns, which grows without bound and misrepresents
1750        // compacted conversations.
1751        let context_percent = if self.context_window > 0 {
1752            self.current_context_tokens as f64 / self.context_window as f64
1753        } else {
1754            0.0
1755        };
1756        let mut extension_items = self.status_items.clone();
1757        if let Some(info) = self.current_oauth_display_info() {
1758            extension_items.insert("oauth".into(), info.status_summary());
1759        }
1760        let active_tools = self
1761            .messages
1762            .iter()
1763            .flat_map(|m| m.tool_calls.iter())
1764            .filter(|tc| tc.output.is_none() && !tc.is_error)
1765            .count() as u32;
1766
1767        StatusInfo {
1768            cwd,
1769            session_name,
1770            model: self.model_name.clone(),
1771            thinking: format!("{:?}", self.thinking_level),
1772            input_tokens: total_input,
1773            output_tokens: total_output,
1774            current_context_tokens,
1775            cost: self.accumulated_cost.total,
1776            context_percent,
1777            context_window: self.context_window,
1778            show_cost: self.config.ui.show_cost,
1779            show_context_usage: self.config.ui.show_context_usage,
1780            peek: self.tools_expanded,
1781            extension_items,
1782            is_streaming: self.is_streaming,
1783            active_tools,
1784            turn_elapsed: self.is_streaming.then(|| self.turn_tracker.elapsed()),
1785            tick: self.tick,
1786            animation_level: self.config.ui.animations,
1787            activity_state: self.current_activity_state(),
1788        }
1789    }
1790
1791    fn current_oauth_display_info(&self) -> Option<imp_llm::auth::OAuthDisplayInfo> {
1792        let auth_path = imp_core::storage::global_auth_path();
1793        let auth_store = AuthStore::load(&auth_path).ok()?;
1794        let meta = self.model_registry.resolve_meta(&self.model_name, None)?;
1795        let mut provider_name = meta.provider.clone();
1796        if should_use_chatgpt_provider(&auth_store, &self.model_registry, &meta) {
1797            provider_name = "openai-codex".to_string();
1798        }
1799        auth_store.oauth_display_info(&provider_name)
1800    }
1801
1802    fn current_model_meta_for_persistence(&self) -> Option<ModelMeta> {
1803        let auth_path = imp_core::storage::global_auth_path();
1804        let auth_store = AuthStore::load(&auth_path).ok();
1805        let mut meta = self.model_registry.resolve_meta(&self.model_name, None)?;
1806
1807        if let Some(auth_store) = auth_store.as_ref() {
1808            if should_use_chatgpt_provider(auth_store, &self.model_registry, &meta) {
1809                meta = self
1810                    .model_registry
1811                    .resolve_meta(&self.model_name, Some("openai-codex"))?;
1812            }
1813        }
1814
1815        Some(meta)
1816    }
1817
1818    // ── Key handling ────────────────────────────────────────────
1819
1820    fn handle_key(&mut self, key: KeyEvent) -> Result<(), Box<dyn std::error::Error>> {
1821        self.needs_redraw = true;
1822
1823        if self.ask_state.is_some() && self.is_paste_shortcut(key) {
1824            self.paste_from_clipboard();
1825            return Ok(());
1826        }
1827
1828        // Reset ctrl+c counter on non-ctrl+c keypress
1829        if !(key.code == KeyCode::Char('c')
1830            && (key.modifiers.contains(KeyModifiers::CONTROL)
1831                || key.modifiers.contains(KeyModifiers::SUPER)))
1832        {
1833            self.ctrl_c_count = 0;
1834        }
1835
1836        // Ask overlay intercepts all keys when active
1837        if self.ask_state.is_some() {
1838            self.handle_ask_key(key);
1839            return Ok(());
1840        }
1841
1842        // Route based on current UI mode
1843        match &self.mode {
1844            UiMode::Normal => self.handle_normal_key(key)?,
1845            UiMode::ModelSelector(_)
1846            | UiMode::CommandPalette(_)
1847            | UiMode::FileFinder(_)
1848            | UiMode::LoginPicker(_)
1849            | UiMode::SecretsPicker(_) => self.handle_overlay_key(key),
1850            UiMode::Personality(_) => self.handle_personality_key(key),
1851            UiMode::TreeView(_) => self.handle_tree_key(key),
1852            UiMode::Settings(_) => self.handle_settings_key(key),
1853            UiMode::SessionPicker(_) => self.handle_session_picker_key(key),
1854            UiMode::Welcome(_) => self.handle_welcome_key(key),
1855        }
1856
1857        Ok(())
1858    }
1859
1860    fn handle_normal_key(&mut self, key: KeyEvent) -> Result<(), Box<dyn std::error::Error>> {
1861        if self.is_copy_shortcut(key) {
1862            let _ = self.copy_selection();
1863            return Ok(());
1864        }
1865        if self.is_paste_shortcut(key) {
1866            self.paste_from_clipboard();
1867            return Ok(());
1868        }
1869
1870        if key.modifiers.contains(KeyModifiers::SHIFT) {
1871            match key.code {
1872                KeyCode::Up => {
1873                    if self.extend_selection_lines(-1) {
1874                        return Ok(());
1875                    }
1876                }
1877                KeyCode::Down => {
1878                    if self.extend_selection_lines(1) {
1879                        return Ok(());
1880                    }
1881                }
1882                KeyCode::PageUp => {
1883                    if self.extend_selection_lines(-(self.config.ui.keyboard_scroll_lines as isize))
1884                    {
1885                        return Ok(());
1886                    }
1887                }
1888                KeyCode::PageDown => {
1889                    if self.extend_selection_lines(self.config.ui.keyboard_scroll_lines as isize) {
1890                        return Ok(());
1891                    }
1892                }
1893                _ => {}
1894            }
1895        }
1896
1897        if key.code == KeyCode::Esc && self.selection.is_some() {
1898            self.clear_selection();
1899            return Ok(());
1900        }
1901
1902        let action = keybindings::resolve_normal(key);
1903
1904        match action {
1905            Some(Action::Submit) => {
1906                if self.is_streaming {
1907                    // Queue steering message
1908                    let text = self.editor.content().to_string();
1909                    if !text.trim().is_empty() {
1910                        self.message_queue.push(QueuedMessage::Steer(text));
1911                        self.editor.clear();
1912                        // Send to agent
1913                        if let Some(ref handle) = self.agent_handle {
1914                            let _ = handle.command_tx.try_send(AgentCommand::Steer(
1915                                self.message_queue
1916                                    .last()
1917                                    .map(|m| match m {
1918                                        QueuedMessage::Steer(s) => s.clone(),
1919                                        QueuedMessage::FollowUp(s) => s.clone(),
1920                                    })
1921                                    .unwrap_or_default(),
1922                            ));
1923                        }
1924                    }
1925                } else {
1926                    self.send_message();
1927                }
1928            }
1929            Some(Action::FollowUp) => {
1930                if self.is_streaming {
1931                    let text = self.editor.content().to_string();
1932                    if !text.trim().is_empty() {
1933                        self.message_queue.push(QueuedMessage::FollowUp(text));
1934                        self.editor.clear();
1935                    }
1936                }
1937            }
1938            Some(Action::NewLine) => {
1939                self.editor.insert_newline();
1940            }
1941            Some(Action::Cancel) => {
1942                self.handle_cancel();
1943            }
1944            Some(Action::SelectModel) => {
1945                self.open_model_selector();
1946            }
1947            Some(Action::CycleModelForward) => {
1948                self.cycle_model(true);
1949            }
1950            Some(Action::CycleModelBackward) => {
1951                self.cycle_model(false);
1952            }
1953            Some(Action::CycleThinking) => {
1954                self.cycle_thinking_level();
1955            }
1956            Some(Action::SidebarToggle) => {
1957                self.toggle_sidebar();
1958            }
1959            Some(Action::Peek) => {
1960                // Legacy alias — behaves the same as ToolToggle with no focus
1961                self.tools_expanded = !self.tools_expanded;
1962                for msg in &mut self.messages {
1963                    for tc in &mut msg.tool_calls {
1964                        tc.expanded = self.tools_expanded;
1965                    }
1966                }
1967                self.invalidate_chat_render_cache();
1968            }
1969            Some(Action::OpenSelectedReadFile) => {
1970                self.open_selected_read_file();
1971            }
1972            Some(Action::ToolToggle) => {
1973                if let Some(idx) = self.tool_focus {
1974                    // Toggle just the focused tool call
1975                    if let Some(tc) = self.get_tool_call_mut(idx) {
1976                        tc.expanded = !tc.expanded;
1977                    }
1978                    self.invalidate_chat_render_cache();
1979                } else {
1980                    // No focus: toggle all (global expand/collapse)
1981                    self.tools_expanded = !self.tools_expanded;
1982                    for msg in &mut self.messages {
1983                        for tc in &mut msg.tool_calls {
1984                            tc.expanded = self.tools_expanded;
1985                        }
1986                    }
1987                    self.invalidate_chat_render_cache();
1988                }
1989            }
1990            Some(Action::ToolFocusNext) => {
1991                let total = self.total_tool_calls();
1992                if total > 0 {
1993                    if !self.sidebar.open {
1994                        self.sidebar.open = true;
1995                        self.focus_latest_tool_with_pin(false);
1996                    } else {
1997                        let idx = match self.tool_focus {
1998                            None => 0,
1999                            Some(i) => (i + 1).min(total - 1),
2000                        };
2001                        self.focus_tool(idx);
2002                    }
2003                }
2004            }
2005            Some(Action::ToolFocusPrev) => {
2006                let total = self.total_tool_calls();
2007                if total > 0 {
2008                    if !self.sidebar.open {
2009                        self.sidebar.open = true;
2010                        self.focus_latest_tool_with_pin(false);
2011                    } else {
2012                        let idx = match self.tool_focus {
2013                            None => total.saturating_sub(1),
2014                            Some(i) => i.saturating_sub(1),
2015                        };
2016                        self.focus_tool(idx);
2017                    }
2018                }
2019            }
2020            Some(Action::InsertChar('@')) => {
2021                self.editor.insert_char('@');
2022                self.open_file_finder();
2023            }
2024            Some(Action::InsertChar('/')) if self.editor.is_empty() && !self.is_streaming => {
2025                self.editor.insert_char('/');
2026                self.mode = UiMode::CommandPalette(CommandPaletteState::new(builtin_commands()));
2027            }
2028            Some(Action::InsertChar(c)) => {
2029                self.editor.insert_char(c);
2030            }
2031            Some(Action::Backspace) => {
2032                self.editor.delete_back();
2033            }
2034            Some(Action::Delete) => {
2035                self.editor.delete_forward();
2036            }
2037            Some(Action::CursorLeft) => {
2038                self.editor.move_left();
2039            }
2040            Some(Action::CursorRight) => {
2041                self.editor.move_right();
2042            }
2043            Some(Action::CursorUp) => {
2044                if self.sidebar.open && self.active_pane == Pane::SidebarList {
2045                    let total = self.total_tool_calls();
2046                    if total > 0 {
2047                        let idx = match self.tool_focus {
2048                            None => total.saturating_sub(1),
2049                            Some(i) => i.saturating_sub(1),
2050                        };
2051                        self.focus_tool(idx);
2052                    }
2053                } else if !self.editor.move_up() {
2054                    self.editor.history_prev();
2055                }
2056            }
2057            Some(Action::CursorDown) => {
2058                if self.sidebar.open && self.active_pane == Pane::SidebarList {
2059                    let total = self.total_tool_calls();
2060                    if total > 0 {
2061                        let idx = match self.tool_focus {
2062                            None => 0,
2063                            Some(i) => (i + 1).min(total - 1),
2064                        };
2065                        self.focus_tool(idx);
2066                    }
2067                } else if !self.editor.move_down() {
2068                    self.editor.history_next();
2069                }
2070            }
2071            Some(Action::CursorHome) => {
2072                self.editor.move_home();
2073            }
2074            Some(Action::CursorEnd) => {
2075                self.editor.move_end();
2076            }
2077            Some(Action::WordLeft) => {
2078                self.editor.move_word_left();
2079            }
2080            Some(Action::WordRight) => {
2081                self.editor.move_word_right();
2082            }
2083            Some(Action::DeleteWordBack) => {
2084                self.editor.delete_word_back();
2085            }
2086            Some(Action::DeleteToStart) => {
2087                self.editor.delete_to_start();
2088            }
2089            Some(Action::DeleteToEnd) => {
2090                self.editor.delete_to_end();
2091            }
2092            Some(Action::ScrollUp) | Some(Action::PageUp) => {
2093                self.scroll_active_pane_up(self.config.ui.keyboard_scroll_lines);
2094            }
2095            Some(Action::ScrollDown) | Some(Action::PageDown) => {
2096                self.scroll_active_pane_down(self.config.ui.keyboard_scroll_lines);
2097            }
2098            Some(Action::Quit) => {
2099                self.handle_cancel();
2100            }
2101            _ => {}
2102        }
2103
2104        Ok(())
2105    }
2106
2107    fn handle_overlay_key(&mut self, key: KeyEvent) {
2108        let action = keybindings::resolve_overlay(key);
2109
2110        match action {
2111            Some(Action::OverlayDismiss) => {
2112                // If dismissing command palette, clear the editor's slash prefix
2113                if matches!(self.mode, UiMode::CommandPalette(_)) {
2114                    self.editor.clear();
2115                }
2116                self.mode = UiMode::Normal;
2117            }
2118            Some(Action::OverlayUp) => match &mut self.mode {
2119                UiMode::ModelSelector(s) => s.move_up(),
2120                UiMode::CommandPalette(s) => s.move_up(),
2121                UiMode::FileFinder(s) => s.move_up(),
2122                UiMode::LoginPicker(s) => s.move_up(),
2123                UiMode::SecretsPicker(s) => s.move_up(),
2124                _ => {}
2125            },
2126            Some(Action::OverlayDown) => match &mut self.mode {
2127                UiMode::ModelSelector(s) => s.move_down(),
2128                UiMode::CommandPalette(s) => s.move_down(),
2129                UiMode::FileFinder(s) => s.move_down(),
2130                UiMode::LoginPicker(s) => s.move_down(),
2131                UiMode::SecretsPicker(s) => s.move_down(),
2132                _ => {}
2133            },
2134            Some(Action::OverlayFilter(c)) => match &mut self.mode {
2135                UiMode::ModelSelector(s) => s.push_filter(c),
2136                UiMode::CommandPalette(s) => {
2137                    s.push_filter(c);
2138                    self.editor.insert_char(c);
2139                }
2140                UiMode::FileFinder(s) => s.push_filter(c),
2141                _ => {}
2142            },
2143            Some(Action::OverlayBackspace) => match &mut self.mode {
2144                UiMode::ModelSelector(s) => s.pop_filter(),
2145                UiMode::CommandPalette(s) => {
2146                    s.pop_filter();
2147                    self.editor.delete_back();
2148                    // If editor is empty (backspaced past /), dismiss
2149                    if self.editor.is_empty() {
2150                        self.mode = UiMode::Normal;
2151                    }
2152                }
2153                UiMode::FileFinder(s) => s.pop_filter(),
2154                _ => {}
2155            },
2156            Some(Action::OverlaySelect) => {
2157                self.handle_overlay_select();
2158            }
2159            _ => {}
2160        }
2161    }
2162
2163    fn handle_overlay_select(&mut self) {
2164        // Take ownership of mode to process selection
2165        let old_mode = std::mem::replace(&mut self.mode, UiMode::Normal);
2166        match old_mode {
2167            UiMode::ModelSelector(state) => {
2168                if let Some(selection) = state.selected_choice() {
2169                    match selection {
2170                        ModelSelection::Builtin(model) => {
2171                            self.model_name = model.id.clone();
2172                            self.context_window = model.context_window;
2173                        }
2174                        ModelSelection::Custom(model_id) => {
2175                            self.model_name = model_id;
2176                            if let Some(meta) =
2177                                self.model_registry.resolve_meta(&self.model_name, None)
2178                            {
2179                                self.context_window = meta.context_window;
2180                            }
2181                        }
2182                    }
2183                }
2184            }
2185            UiMode::CommandPalette(state) => {
2186                if let Some(cmd) = state.selected_command() {
2187                    self.editor.clear();
2188                    self.execute_command(&cmd.name.clone());
2189                }
2190            }
2191            UiMode::FileFinder(state) => {
2192                if let Some(file) = state.selected_file() {
2193                    self.editor.insert_char(' ');
2194                    for c in file.chars() {
2195                        self.editor.insert_char(c);
2196                    }
2197                }
2198            }
2199            UiMode::LoginPicker(state) => {
2200                if let Some(provider) = state.selected_provider() {
2201                    self.start_login(provider.id);
2202                }
2203            }
2204            UiMode::SecretsPicker(state) => {
2205                if let Some(provider) = state.selected_provider() {
2206                    self.start_secrets_flow(&provider.id);
2207                }
2208            }
2209            _ => {
2210                self.mode = old_mode;
2211            }
2212        }
2213    }
2214
2215    fn handle_tree_key(&mut self, key: KeyEvent) {
2216        match key.code {
2217            KeyCode::Esc | KeyCode::Tab => {
2218                self.mode = UiMode::Normal;
2219            }
2220            KeyCode::Up | KeyCode::Char('k') => {
2221                if let UiMode::TreeView(ref mut state) = self.mode {
2222                    state.move_up();
2223                }
2224            }
2225            KeyCode::Down | KeyCode::Char('j') => {
2226                if let UiMode::TreeView(ref mut state) = self.mode {
2227                    state.move_down();
2228                }
2229            }
2230            KeyCode::Enter => {
2231                let selected_id = if let UiMode::TreeView(ref state) = self.mode {
2232                    state.selected_id().map(String::from)
2233                } else {
2234                    None
2235                };
2236                if let Some(id) = selected_id {
2237                    let _ = self.session.navigate(&id);
2238                    self.load_session_messages();
2239                    self.mode = UiMode::Normal;
2240                }
2241            }
2242            KeyCode::Char('f') => {
2243                let selected_id = if let UiMode::TreeView(ref state) = self.mode {
2244                    state.selected_id().map(String::from)
2245                } else {
2246                    None
2247                };
2248                if let Some(id) = selected_id {
2249                    let path = imp_core::storage::global_sessions_dir()
2250                        .join(format!("{}.jsonl", uuid::Uuid::new_v4()));
2251                    match self.session.fork(&id, &path) {
2252                        Ok(forked) => {
2253                            self.session = forked;
2254                            self.load_session_messages();
2255                            self.mode = UiMode::Normal;
2256                            self.push_system_msg(
2257                                "Forked from selected tree node. You're on a new branch.",
2258                            );
2259                        }
2260                        Err(e) => {
2261                            self.mode = UiMode::Normal;
2262                            self.push_system_msg(&format!("Fork failed: {e}"));
2263                        }
2264                    }
2265                }
2266            }
2267            KeyCode::Char('o') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2268                if let UiMode::TreeView(ref mut state) = self.mode {
2269                    state.cycle_filter();
2270                }
2271            }
2272            _ => {}
2273        }
2274    }
2275
2276    // ── Tool focus helpers ───────────────────────────────────────
2277
2278    /// Find a tool call's flat index by ID across all display messages.
2279    fn find_tool_call_index(&self, id: &str) -> Option<usize> {
2280        let mut index = 0;
2281        for msg in &self.messages {
2282            for tc in &msg.tool_calls {
2283                if tc.id == id {
2284                    return Some(index);
2285                }
2286                index += 1;
2287            }
2288        }
2289        None
2290    }
2291
2292    /// Focus a tool call by flat index: update tool_focus and sync sidebar.
2293    fn focus_tool(&mut self, index: usize) {
2294        self.focus_tool_with_pin(index, true);
2295    }
2296
2297    fn focus_latest_tool_with_pin(&mut self, pinned: bool) -> bool {
2298        let total = self.total_tool_calls();
2299        if total == 0 {
2300            return false;
2301        }
2302        self.focus_tool_with_pin(total - 1, pinned);
2303        true
2304    }
2305
2306    fn focus_tool_with_pin(&mut self, index: usize, pinned: bool) {
2307        self.tool_focus = Some(index);
2308        self.tool_focus_pinned = pinned;
2309        self.sidebar_auto_follow = !pinned;
2310        self.sidebar.open = true;
2311        self.sidebar.reset_detail_scroll();
2312        self.active_pane = match self.config.ui.sidebar_style {
2313            imp_core::config::SidebarStyle::Split => Pane::SidebarList,
2314            imp_core::config::SidebarStyle::Inspector | imp_core::config::SidebarStyle::Stream => {
2315                Pane::SidebarDetail
2316            }
2317        };
2318        if self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Split {
2319            self.sidebar.ensure_selected_visible(index);
2320        }
2321    }
2322
2323    fn selected_read_file_path(&self) -> Option<PathBuf> {
2324        selected_read_file_path_from_tool(self.selected_tool_call().as_ref(), &self.cwd)
2325    }
2326
2327    fn open_selected_read_file(&mut self) {
2328        let Some(path) = self.selected_read_file_path() else {
2329            self.push_system_msg("No read file selected to open.");
2330            return;
2331        };
2332
2333        if !path.is_file() {
2334            self.push_error_msg(&format!(
2335                "Selected read file does not exist: {}",
2336                path.display()
2337            ));
2338            return;
2339        }
2340
2341        match open_path_in_editor(&path) {
2342            Ok(()) => self.push_system_msg(&format!("Opened {}", path.display())),
2343            Err(error) => {
2344                self.push_error_msg(&format!("Failed to open {}: {error}", path.display()))
2345            }
2346        }
2347    }
2348
2349    fn toggle_sidebar(&mut self) {
2350        if self.sidebar.open {
2351            self.sidebar.open = false;
2352            self.active_pane = Pane::Chat;
2353        } else {
2354            self.sidebar.open = true;
2355            if self.tool_focus.is_none() && !self.focus_latest_tool_with_pin(false) {
2356                self.active_pane = Pane::Chat;
2357            } else {
2358                self.active_pane = Pane::SidebarDetail;
2359            }
2360        }
2361    }
2362
2363    fn tool_id_at_chat_row(&self, row: u16, chat_area: Rect) -> Option<String> {
2364        build_click_map(
2365            &self.messages,
2366            &self.theme,
2367            &self.highlighter,
2368            chat_area,
2369            self.scroll_offset,
2370            self.config.ui.word_wrap,
2371            self.config.ui.effective_chat_tool_display(),
2372            self.config.ui.thinking_lines,
2373            self.config.ui.show_timestamps,
2374        )
2375        .into_iter()
2376        .find_map(|(tool_row, tool_id)| (tool_row == row).then_some(tool_id))
2377    }
2378
2379    /// Total number of tool calls across all display messages.
2380    fn total_tool_calls(&self) -> usize {
2381        self.messages.iter().map(|m| m.tool_calls.len()).sum()
2382    }
2383
2384    /// Mutable access to a tool call by its flat index across all messages.
2385    fn get_tool_call_mut(
2386        &mut self,
2387        flat_idx: usize,
2388    ) -> Option<&mut crate::views::tools::DisplayToolCall> {
2389        let mut remaining = flat_idx;
2390        for msg in &mut self.messages {
2391            if remaining < msg.tool_calls.len() {
2392                return Some(&mut msg.tool_calls[remaining]);
2393            }
2394            remaining -= msg.tool_calls.len();
2395        }
2396        None
2397    }
2398
2399    fn scroll_chat_up(&mut self, lines: usize) {
2400        self.scroll_offset = self.scroll_offset.saturating_add(lines);
2401        self.auto_scroll = false;
2402    }
2403
2404    fn scroll_chat_down(&mut self, lines: usize) {
2405        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
2406        if self.scroll_offset == 0 {
2407            self.auto_scroll = true;
2408        }
2409    }
2410
2411    fn scroll_active_pane_up(&mut self, lines: usize) {
2412        match self.active_pane {
2413            Pane::SidebarList if self.sidebar.open => self.sidebar.scroll_list_up(lines),
2414            Pane::SidebarDetail if self.sidebar.open => {
2415                self.sidebar_auto_follow = false;
2416                self.sidebar.scroll_detail_up(lines);
2417            }
2418            _ => self.scroll_chat_up(lines),
2419        }
2420    }
2421
2422    fn scroll_active_pane_down(&mut self, lines: usize) {
2423        match self.active_pane {
2424            Pane::SidebarList if self.sidebar.open => self.sidebar.scroll_list_down(lines),
2425            Pane::SidebarDetail if self.sidebar.open => {
2426                self.sidebar_auto_follow = false;
2427                self.sidebar.scroll_detail_down(lines);
2428            }
2429            _ => self.scroll_chat_down(lines),
2430        }
2431    }
2432
2433    fn selection_surface(&self, pane: SelectablePane) -> Option<&TextSurface> {
2434        match pane {
2435            SelectablePane::Chat => self.chat_surface.as_ref(),
2436            SelectablePane::SidebarDetail => self.sidebar_detail_surface.as_ref(),
2437        }
2438    }
2439
2440    fn clear_selection(&mut self) {
2441        self.selection = None;
2442        self.drag_selection = None;
2443        self.drag_autoscroll = None;
2444    }
2445
2446    fn selection_text(&self) -> Option<String> {
2447        let selection = self.selection.as_ref()?;
2448        let surface = self.selection_surface(selection.pane)?;
2449        extract_selected_text(surface, selection).filter(|text| !text.is_empty())
2450    }
2451
2452    fn copy_to_clipboard(&self, text: &str) {
2453        #[cfg(target_os = "macos")]
2454        {
2455            let _ = Self::write_to_clipboard_command("pbcopy", &[], text);
2456        }
2457        #[cfg(target_os = "linux")]
2458        {
2459            let _ = Self::write_to_clipboard_linux(text);
2460        }
2461    }
2462
2463    #[cfg(any(target_os = "macos", target_os = "linux"))]
2464    fn write_to_clipboard_command(program: &str, args: &[&str], text: &str) -> bool {
2465        use std::io::Write;
2466
2467        let Ok(mut child) = std::process::Command::new(program)
2468            .args(args)
2469            .stdin(std::process::Stdio::piped())
2470            .stdout(std::process::Stdio::null())
2471            .stderr(std::process::Stdio::null())
2472            .spawn()
2473        else {
2474            return false;
2475        };
2476
2477        if let Some(mut stdin) = child.stdin.take() {
2478            if stdin.write_all(text.as_bytes()).is_err() {
2479                return false;
2480            }
2481        }
2482
2483        child.wait().is_ok_and(|status| status.success())
2484    }
2485
2486    #[cfg(target_os = "linux")]
2487    fn write_to_clipboard_linux(text: &str) -> bool {
2488        Self::write_to_clipboard_command("wl-copy", &[], text)
2489            || Self::write_to_clipboard_command("xclip", &["-selection", "clipboard"], text)
2490            || Self::write_to_clipboard_command("xsel", &["--clipboard", "--input"], text)
2491    }
2492
2493    fn copy_selection(&mut self) -> bool {
2494        if let Some(text) = self.selection_text() {
2495            self.copy_to_clipboard(&text);
2496            self.push_system_msg("Copied selection to clipboard.");
2497            true
2498        } else {
2499            false
2500        }
2501    }
2502
2503    fn is_copy_shortcut(&self, key: KeyEvent) -> bool {
2504        key.code == KeyCode::Char('c')
2505            && (key.modifiers.contains(KeyModifiers::CONTROL)
2506                || key.modifiers.contains(KeyModifiers::SUPER))
2507            && self.selection.is_some()
2508    }
2509
2510    fn is_paste_shortcut(&self, key: KeyEvent) -> bool {
2511        key.code == KeyCode::Char('v')
2512            && (key.modifiers.contains(KeyModifiers::CONTROL)
2513                || key.modifiers.contains(KeyModifiers::SUPER))
2514    }
2515
2516    #[cfg(any(target_os = "macos", target_os = "linux"))]
2517    fn read_clipboard_command(program: &str, args: &[&str]) -> Option<String> {
2518        let output = std::process::Command::new(program)
2519            .args(args)
2520            .stdin(std::process::Stdio::null())
2521            .stdout(std::process::Stdio::piped())
2522            .stderr(std::process::Stdio::null())
2523            .output()
2524            .ok()?;
2525        if !output.status.success() {
2526            return None;
2527        }
2528        String::from_utf8(output.stdout).ok()
2529    }
2530
2531    fn read_clipboard_text(&self) -> Option<String> {
2532        #[cfg(target_os = "macos")]
2533        {
2534            return Self::read_clipboard_command("pbpaste", &[]);
2535        }
2536        #[cfg(target_os = "linux")]
2537        {
2538            return Self::read_clipboard_command("wl-paste", &["--no-newline"])
2539                .or_else(|| {
2540                    Self::read_clipboard_command("xclip", &["-selection", "clipboard", "-o"])
2541                })
2542                .or_else(|| Self::read_clipboard_command("xsel", &["--clipboard", "--output"]));
2543        }
2544        #[allow(unreachable_code)]
2545        None
2546    }
2547
2548    fn paste_from_clipboard(&mut self) -> bool {
2549        let Some(text) = self.read_clipboard_text() else {
2550            return false;
2551        };
2552
2553        self.handle_paste(text);
2554        true
2555    }
2556
2557    fn handle_paste(&mut self, text: String) {
2558        for ch in text.chars() {
2559            match ch {
2560                '\n' => self.editor.insert_newline(),
2561                '\r' => {}
2562                c => self.editor.insert_char(c),
2563            }
2564        }
2565        if self.ask_state.is_some() {
2566            self.sync_ask_from_editor();
2567        }
2568        self.needs_redraw = true;
2569    }
2570
2571    fn extend_selection_lines(&mut self, delta: isize) -> bool {
2572        let Some(mut selection) = self.selection.clone() else {
2573            return false;
2574        };
2575        let Some(surface) = self.selection_surface(selection.pane) else {
2576            return false;
2577        };
2578
2579        selection.focus = surface.move_pos(selection.focus, delta, 0);
2580        match selection.pane {
2581            SelectablePane::Chat => {
2582                if selection.focus.line < surface.top_line {
2583                    self.scroll_chat_up(surface.top_line - selection.focus.line);
2584                } else {
2585                    let bottom = surface.top_line + surface.rect.height.saturating_sub(1) as usize;
2586                    if selection.focus.line > bottom {
2587                        self.scroll_chat_down(selection.focus.line - bottom);
2588                    }
2589                }
2590            }
2591            SelectablePane::SidebarDetail => {
2592                if selection.focus.line < surface.top_line {
2593                    self.sidebar
2594                        .scroll_detail_up(surface.top_line - selection.focus.line);
2595                } else {
2596                    let bottom = surface.top_line + surface.rect.height.saturating_sub(1) as usize;
2597                    if selection.focus.line > bottom {
2598                        self.sidebar
2599                            .scroll_detail_down(selection.focus.line - bottom);
2600                    }
2601                }
2602            }
2603        }
2604
2605        self.selection = Some(selection);
2606        true
2607    }
2608
2609    fn set_drag_autoscroll(
2610        &mut self,
2611        pane: SelectablePane,
2612        surface: &TextSurface,
2613        col: u16,
2614        row: u16,
2615    ) {
2616        let top_margin = surface.rect.y.saturating_add(1);
2617        let bottom_margin = surface
2618            .rect
2619            .y
2620            .saturating_add(surface.rect.height.saturating_sub(2));
2621
2622        let next = if row <= top_margin {
2623            let speed = if row <= surface.rect.y { 3 } else { 1 };
2624            Some(DragAutoScroll {
2625                pane,
2626                direction: ScrollDirection::Up,
2627                speed,
2628                column: col,
2629                row,
2630            })
2631        } else if row >= bottom_margin {
2632            let lower_edge = surface.rect.y + surface.rect.height.saturating_sub(1);
2633            let speed = if row >= lower_edge { 3 } else { 1 };
2634            Some(DragAutoScroll {
2635                pane,
2636                direction: ScrollDirection::Down,
2637                speed,
2638                column: col,
2639                row,
2640            })
2641        } else {
2642            None
2643        };
2644
2645        self.drag_autoscroll = next;
2646    }
2647
2648    fn maybe_autoscroll_selection(&mut self) {
2649        let Some(auto) = self.drag_autoscroll else {
2650            return;
2651        };
2652        if self.drag_selection != Some(auto.pane) {
2653            self.drag_autoscroll = None;
2654            return;
2655        }
2656
2657        let Some(surface) = self.selection_surface(auto.pane).cloned() else {
2658            self.drag_autoscroll = None;
2659            return;
2660        };
2661
2662        let changed = match (auto.pane, auto.direction) {
2663            (SelectablePane::Chat, ScrollDirection::Up) => {
2664                let before = self.scroll_offset;
2665                self.scroll_chat_up(auto.speed);
2666                self.scroll_offset != before
2667            }
2668            (SelectablePane::Chat, ScrollDirection::Down) => {
2669                let before = self.scroll_offset;
2670                self.scroll_chat_down(auto.speed);
2671                self.scroll_offset != before
2672            }
2673            (SelectablePane::SidebarDetail, ScrollDirection::Up) => {
2674                let before = self.sidebar.detail_scroll;
2675                self.sidebar.scroll_detail_up(auto.speed);
2676                self.sidebar.detail_scroll != before
2677            }
2678            (SelectablePane::SidebarDetail, ScrollDirection::Down) => {
2679                let before = self.sidebar.detail_scroll;
2680                self.sidebar.scroll_detail_down(auto.speed);
2681                self.sidebar.detail_scroll != before
2682            }
2683        };
2684
2685        if !changed {
2686            return;
2687        }
2688
2689        if let Some(selection) = self.selection.as_mut() {
2690            if selection.pane == auto.pane {
2691                selection.focus = surface.pos_from_screen_clamped(auto.column, auto.row);
2692                self.needs_redraw = true;
2693            }
2694        }
2695    }
2696
2697    fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
2698        self.needs_redraw = true;
2699
2700        // Session picker intercepts scroll events
2701        if matches!(self.mode, UiMode::SessionPicker(_)) {
2702            match mouse.kind {
2703                MouseEventKind::ScrollUp => {
2704                    if let UiMode::SessionPicker(ref mut state) = self.mode {
2705                        state.move_up();
2706                    }
2707                }
2708                MouseEventKind::ScrollDown => {
2709                    if let UiMode::SessionPicker(ref mut state) = self.mode {
2710                        state.move_down();
2711                    }
2712                }
2713                _ => {}
2714            }
2715            return;
2716        }
2717
2718        let col = mouse.column;
2719        let row = mouse.row;
2720
2721        let is_stream = self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Stream;
2722        let is_inspector =
2723            self.config.ui.sidebar_style == imp_core::config::SidebarStyle::Inspector;
2724        let in_list = point_in_rect(col, row, self.sidebar_list_rect);
2725        let in_detail = point_in_rect(col, row, self.sidebar_detail_rect);
2726        let in_sidebar = in_list || in_detail;
2727
2728        match mouse.kind {
2729            MouseEventKind::ScrollUp => {
2730                if in_list && !is_inspector {
2731                    self.active_pane = Pane::SidebarList;
2732                    self.sidebar
2733                        .scroll_list_up(self.config.ui.mouse_scroll_lines);
2734                } else if in_detail || (in_sidebar && (is_stream || is_inspector)) {
2735                    self.active_pane = Pane::SidebarDetail;
2736                    self.sidebar_auto_follow = false;
2737                    self.sidebar
2738                        .scroll_detail_up(self.config.ui.mouse_scroll_lines);
2739                } else {
2740                    self.active_pane = Pane::Chat;
2741                    self.scroll_chat_up(self.config.ui.mouse_scroll_lines);
2742                }
2743            }
2744            MouseEventKind::ScrollDown => {
2745                if in_list && !is_inspector {
2746                    self.active_pane = Pane::SidebarList;
2747                    self.sidebar
2748                        .scroll_list_down(self.config.ui.mouse_scroll_lines);
2749                } else if in_detail || (in_sidebar && (is_stream || is_inspector)) {
2750                    self.active_pane = Pane::SidebarDetail;
2751                    self.sidebar_auto_follow = false;
2752                    self.sidebar
2753                        .scroll_detail_down(self.config.ui.mouse_scroll_lines);
2754                } else {
2755                    self.active_pane = Pane::Chat;
2756                    self.scroll_chat_down(self.config.ui.mouse_scroll_lines);
2757                }
2758            }
2759            MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
2760                if in_list && !is_inspector {
2761                    self.clear_selection();
2762                    self.active_pane = Pane::SidebarList;
2763                    if let Some(lr) = self.sidebar_list_rect {
2764                        let clicked_row = (row - lr.y) as usize;
2765                        let clicked_idx = self.sidebar.list_scroll + clicked_row;
2766                        let total = self.total_tool_calls();
2767                        if clicked_idx < total {
2768                            self.focus_tool(clicked_idx);
2769                        }
2770                    }
2771                    return;
2772                }
2773
2774                if in_detail || (in_sidebar && (is_stream || is_inspector)) {
2775                    self.active_pane = Pane::SidebarDetail;
2776                    if let Some(surface) = self.sidebar_detail_surface.as_ref().cloned() {
2777                        if !surface.is_empty() {
2778                            let pos = surface.pos_from_screen_clamped(col, row);
2779                            self.selection =
2780                                Some(SelectionState::new(SelectablePane::SidebarDetail, pos, pos));
2781                            self.drag_selection = Some(SelectablePane::SidebarDetail);
2782                            self.set_drag_autoscroll(
2783                                SelectablePane::SidebarDetail,
2784                                &surface,
2785                                col,
2786                                row,
2787                            );
2788                        }
2789                    }
2790                    return;
2791                }
2792
2793                self.active_pane = Pane::Chat;
2794                if let Some(chat_area) = self.chat_surface.as_ref().map(|surface| surface.rect) {
2795                    if let Some(tool_id) = self.tool_id_at_chat_row(row, chat_area) {
2796                        self.clear_selection();
2797                        if let Some(index) = self.find_tool_call_index(&tool_id) {
2798                            self.focus_tool(index);
2799                        }
2800                        return;
2801                    }
2802                }
2803
2804                if let Some(surface) = self.chat_surface.as_ref().cloned() {
2805                    if !surface.is_empty() {
2806                        let pos = surface.pos_from_screen_clamped(col, row);
2807                        self.selection = Some(SelectionState::new(SelectablePane::Chat, pos, pos));
2808                        self.drag_selection = Some(SelectablePane::Chat);
2809                        self.set_drag_autoscroll(SelectablePane::Chat, &surface, col, row);
2810                    }
2811                }
2812            }
2813            MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
2814                let Some(pane) = self.drag_selection else {
2815                    return;
2816                };
2817                let Some(surface) = self.selection_surface(pane).cloned() else {
2818                    return;
2819                };
2820                let pos = surface.pos_from_screen_clamped(col, row);
2821                if let Some(selection) = self.selection.as_mut() {
2822                    if selection.pane == pane {
2823                        selection.focus = pos;
2824                    }
2825                }
2826                self.set_drag_autoscroll(pane, &surface, col, row);
2827                match pane {
2828                    SelectablePane::Chat => {
2829                        self.active_pane = Pane::Chat;
2830                    }
2831                    SelectablePane::SidebarDetail => {
2832                        self.active_pane = Pane::SidebarDetail;
2833                    }
2834                }
2835            }
2836            MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
2837                self.drag_selection = None;
2838                self.drag_autoscroll = None;
2839            }
2840            _ => {}
2841        }
2842    }
2843
2844    fn handle_cancel(&mut self) {
2845        if !self.editor.is_empty() {
2846            // First Ctrl+C: clear editor
2847            self.editor.clear();
2848            self.ctrl_c_count = 0;
2849        } else if self.is_streaming || self.agent_task.is_some() {
2850            let already_cancelled = self.agent_handle.as_ref().is_some_and(|handle| {
2851                handle
2852                    .cancel_token
2853                    .load(std::sync::atomic::Ordering::Relaxed)
2854            });
2855            if already_cancelled {
2856                if let Some(task) = self.agent_task.take() {
2857                    task.abort();
2858                }
2859                self.agent_handle = None;
2860            } else if let Some(ref handle) = self.agent_handle {
2861                let _ = handle.command_tx.try_send(AgentCommand::Cancel);
2862                handle
2863                    .cancel_token
2864                    .store(true, std::sync::atomic::Ordering::Relaxed);
2865            }
2866            self.suppress_completion_notification = true;
2867            self.is_streaming = false;
2868            if let Some(last) = self.latest_streaming_message_mut() {
2869                last.is_streaming = false;
2870            }
2871            self.ctrl_c_count = 0;
2872        } else {
2873            // Third: quit
2874            self.ctrl_c_count += 1;
2875            if self.ctrl_c_count >= 2 {
2876                self.running = false;
2877            }
2878        }
2879    }
2880
2881    // ── Commands ────────────────────────────────────────────────
2882
2883    fn spawn_agent_for_prompt(&mut self, prompt: &str) -> Result<(), String> {
2884        let auth_path = imp_core::storage::global_auth_path();
2885        let mut auth_store =
2886            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
2887
2888        let mut meta = self
2889            .model_registry
2890            .resolve_meta(&self.model_name, None)
2891            .ok_or_else(|| format!("Unknown model: {}", self.model_name))?;
2892
2893        let mut provider_name = meta.provider.clone();
2894        if should_use_chatgpt_provider(&auth_store, &self.model_registry, &meta) {
2895            provider_name = "openai-codex".to_string();
2896            meta = self
2897                .model_registry
2898                .resolve_meta(&self.model_name, Some(&provider_name))
2899                .ok_or_else(|| format!("Unknown model: {}", self.model_name))?;
2900        }
2901
2902        let provider = create_provider(&provider_name)
2903            .ok_or_else(|| format!("Unknown provider: {provider_name}"))?;
2904
2905        // Resolve API key with auto-refresh for expired OAuth tokens
2906        let api_key = tokio::task::block_in_place(|| {
2907            tokio::runtime::Handle::current()
2908                .block_on(resolve_provider_api_key(&mut auth_store, &provider_name))
2909        })
2910        .map_err(|e: imp_llm::Error| e.to_string())?;
2911
2912        let model = Model {
2913            meta,
2914            provider: Arc::from(provider),
2915        };
2916
2917        // Override thinking level from the TUI's current selection.
2918        let mut config = self.config.clone();
2919        config.thinking = Some(self.thinking_level);
2920
2921        let requested_max_tokens = self.config.max_tokens;
2922
2923        let lua_cwd = self.cwd.clone();
2924        let user_config_dir = imp_core::config::Config::user_config_dir();
2925        let (mut agent, handle) = AgentBuilder::new(config, self.cwd.clone(), model, api_key)
2926            .lua_tool_loader(move |policy, tools| {
2927                imp_lua::init_lua_extensions(&user_config_dir, Some(&lua_cwd), tools, policy);
2928            })
2929            .build()
2930            .map_err(|e: imp_core::error::Error| e.to_string())?;
2931
2932        // Wire TuiInterface so the ask tool works
2933        let (ui_tx, ui_rx) = tokio::sync::mpsc::channel(16);
2934        agent.ui = crate::tui_interface::TuiInterface::new(ui_tx);
2935        self.ui_rx = Some(ui_rx);
2936
2937        // Apply max_turns override from CLI
2938        if let Some(max_turns) = self.max_turns_override {
2939            agent.max_turns = max_turns;
2940        }
2941        if let Some(max_tokens) = requested_max_tokens {
2942            agent.max_tokens = Some(max_tokens);
2943        }
2944
2945        let mut messages: Vec<Message> = self.session.get_active_messages();
2946        if matches!(
2947            messages.last(),
2948            Some(Message::User(user))
2949                if matches!(
2950                    user.content.as_slice(),
2951                    [imp_llm::ContentBlock::Text { text }] if text == prompt
2952                )
2953        ) {
2954            messages.pop();
2955        }
2956        // Collect tool_result IDs to know which tool_calls are paired (used by sanitize below)
2957        let _result_ids: std::collections::HashSet<String> = messages
2958            .iter()
2959            .filter_map(|m| match m {
2960                Message::ToolResult(tr) => Some(tr.tool_call_id.clone()),
2961                _ => None,
2962            })
2963            .collect();
2964
2965        // Sanitize: strip unpaired tool_calls and orphaned tool_results
2966        imp_core::session::sanitize_messages(&mut messages);
2967        agent.messages = messages;
2968
2969        let prompt = prompt.to_string();
2970        let task = tokio::spawn(async move { agent.run(prompt).await });
2971
2972        self.agent_handle = Some(handle);
2973        self.agent_task = Some(task);
2974        Ok(())
2975    }
2976
2977    fn send_message(&mut self) {
2978        let text = self.editor.content().to_string();
2979        if text.trim().is_empty() {
2980            return;
2981        }
2982
2983        // Check for slash commands
2984        if let Some(cmd_text) = text.strip_prefix('/') {
2985            let typed = cmd_text.trim();
2986            // Resolve prefix: exact match first, then unique prefix match
2987            let commands = builtin_commands();
2988            let cmd = commands
2989                .iter()
2990                .find(|c| c.name == typed)
2991                .or_else(|| commands.iter().find(|c| c.name.starts_with(typed)))
2992                .map(|c| c.name.clone())
2993                .unwrap_or_else(|| typed.to_string());
2994            self.execute_command(&cmd);
2995            self.editor.push_history();
2996            self.editor.clear();
2997            return;
2998        }
2999
3000        // Add user message to display
3001        self.messages.push(DisplayMessage {
3002            role: MessageRole::User,
3003            content: text.clone(),
3004            thinking: None,
3005            tool_calls: Vec::new(),
3006            assistant_blocks: Vec::new(),
3007            is_streaming: false,
3008            timestamp: imp_llm::now(),
3009        });
3010        self.invalidate_chat_render_cache();
3011
3012        // Persist to session
3013        let msg_id = uuid::Uuid::new_v4().to_string();
3014        let _ = self.session.append(SessionEntry::Message {
3015            id: msg_id,
3016            parent_id: None,
3017            message: imp_llm::Message::user(&text),
3018        });
3019
3020        // Add streaming placeholder for assistant response
3021        self.messages.push(DisplayMessage {
3022            role: MessageRole::Assistant,
3023            content: String::new(),
3024            thinking: None,
3025            tool_calls: Vec::new(),
3026            assistant_blocks: Vec::new(),
3027            is_streaming: true,
3028            timestamp: imp_llm::now(),
3029        });
3030        self.invalidate_chat_render_cache();
3031
3032        self.is_streaming = true;
3033        self.completed_turns_in_run = 0;
3034        self.suppress_completion_notification = false;
3035        self.auto_scroll = true;
3036        self.scroll_offset = 0;
3037        self.tool_focus = None;
3038        self.tool_focus_pinned = false;
3039        self.sidebar_auto_follow = true;
3040        self.editor.push_history();
3041        self.editor.clear();
3042
3043        if let Err(error) = self.spawn_agent_for_prompt(&text) {
3044            self.is_streaming = false;
3045            self.messages.pop();
3046            self.messages.push(DisplayMessage {
3047                role: MessageRole::Error,
3048                content: error,
3049                thinking: None,
3050                tool_calls: Vec::new(),
3051                assistant_blocks: Vec::new(),
3052                is_streaming: false,
3053                timestamp: imp_llm::now(),
3054            });
3055            self.invalidate_chat_render_cache();
3056        }
3057    }
3058
3059    fn restore_checkpoint_command(&mut self, needle: &str) {
3060        match self.session.find_checkpoint_record(needle) {
3061            None => self.push_system_msg(&format!("Checkpoint not found: {needle}")),
3062            Some(record) => {
3063                let mut lines = vec![format!(
3064                    "Checkpoint `{}` is recorded for this session, but TUI restore is not wired yet.",
3065                    record.checkpoint_id
3066                )];
3067                if let Some(label) = record.label {
3068                    lines.push(format!("Label: {label}"));
3069                }
3070                if !record.files.is_empty() {
3071                    lines.push("Files:".into());
3072                    for path in record.files {
3073                        lines.push(format!("- {path}"));
3074                    }
3075                }
3076                self.push_system_msg(&lines.join("\n"));
3077            }
3078        }
3079    }
3080
3081    fn execute_command(&mut self, cmd: &str) {
3082        match cmd.split_whitespace().next().unwrap_or("") {
3083            "quit" | "q" => {
3084                self.running = false;
3085            }
3086            "model" => {
3087                self.open_model_selector();
3088            }
3089            "tree" => {
3090                self.open_tree_view();
3091            }
3092            "new" => {
3093                self.messages.clear();
3094                self.invalidate_chat_render_cache();
3095                self.session = SessionManager::in_memory();
3096                self.tool_focus = None;
3097                self.tool_focus_pinned = false;
3098                self.sidebar_auto_follow = true;
3099                self.invalidate_chat_render_cache();
3100                self.accumulated_usage = Usage::default();
3101                self.accumulated_cost = Cost::default();
3102                self.current_context_tokens = 0;
3103            }
3104            "compact" => {
3105                self.run_manual_compaction();
3106            }
3107            "hotkeys" => {
3108                self.push_system_msg(
3109                    "Keyboard shortcuts:\n\
3110  Enter         Send message\n\
3111  Shift+Enter   New line\n\
3112  Alt+Enter     Queue follow-up while streaming\n\
3113  Ctrl+C        Clear / Abort / Quit\n\
3114  Ctrl+C/Cmd+C  Copy selection\n\
3115  Ctrl+V/Cmd+V  Paste clipboard\n\
3116  Ctrl+L        Model selector\n\
3117  Ctrl+P        Next chosen model\n\
3118  Ctrl+Shift+P  Previous chosen model\n\
3119  Tab           Show/hide sidebar\n\
3120  Ctrl+O        Open selected read file in editor\n\
3121  Ctrl+Up/Down  Focus previous/next tool\n\
3122  Shift+Tab     Cycle thinking level\n\
3123  @             File finder\n\
3124  /command      Slash commands\n\
3125  PageUp/Down   Scroll",
3126                );
3127            }
3128            "settings" => {
3129                self.open_settings();
3130            }
3131            "personality" => {
3132                self.open_personality();
3133            }
3134            "resume" => {
3135                let session_dir = imp_core::storage::global_sessions_dir();
3136                match SessionManager::list(&session_dir) {
3137                    Ok(sessions) if !sessions.is_empty() => {
3138                        let state = SessionPickerState::new(sessions, Some(&self.cwd));
3139                        if state.filtered_indices.is_empty() {
3140                            self.messages.push(DisplayMessage {
3141                                role: MessageRole::System,
3142                                content: "No saved sessions found.".into(),
3143                                thinking: None,
3144                                tool_calls: Vec::new(),
3145                                assistant_blocks: Vec::new(),
3146                                is_streaming: false,
3147                                timestamp: imp_llm::now(),
3148                            });
3149                        } else {
3150                            self.mode = UiMode::SessionPicker(state);
3151                        }
3152                    }
3153                    Ok(_) => {
3154                        self.messages.push(DisplayMessage {
3155                            role: MessageRole::System,
3156                            content: "No saved sessions found.".into(),
3157                            thinking: None,
3158                            tool_calls: Vec::new(),
3159                            assistant_blocks: Vec::new(),
3160                            is_streaming: false,
3161                            timestamp: imp_llm::now(),
3162                        });
3163                    }
3164                    Err(e) => {
3165                        self.messages.push(DisplayMessage {
3166                            role: MessageRole::Error,
3167                            content: format!("Failed to list sessions: {e}"),
3168                            thinking: None,
3169                            tool_calls: Vec::new(),
3170                            assistant_blocks: Vec::new(),
3171                            is_streaming: false,
3172                            timestamp: imp_llm::now(),
3173                        });
3174                    }
3175                }
3176            }
3177            "session" => {
3178                self.push_system_msg("/session is defunct. Use /resume to browse/search sessions.");
3179            }
3180            "name" => {
3181                let new_name = cmd.strip_prefix("name").unwrap_or("").trim();
3182                if new_name.is_empty() {
3183                    self.push_system_msg("Usage: /name <session name>");
3184                } else {
3185                    self.session.set_name(new_name);
3186                    self.push_system_msg(&format!("Session renamed to: {new_name}"));
3187                }
3188            }
3189            "export" => {
3190                let dest = cmd.strip_prefix("export").unwrap_or("").trim();
3191                let path = if dest.is_empty() {
3192                    let name = self.session.name().unwrap_or("conversation");
3193                    std::path::PathBuf::from(format!("{name}.md"))
3194                } else {
3195                    std::path::PathBuf::from(dest)
3196                };
3197                match self.export_conversation(&path) {
3198                    Ok(_) => self.push_system_msg(&format!("Exported to {}", path.display())),
3199                    Err(e) => self.push_system_msg(&format!("Export failed: {e}")),
3200                }
3201            }
3202            "reload" => {
3203                match imp_core::config::Config::resolve(
3204                    &imp_core::config::Config::user_config_dir(),
3205                    Some(&self.cwd),
3206                ) {
3207                    Ok(new_config) => {
3208                        self.config = new_config;
3209                        // Reload Lua extensions
3210                        self.reload_lua_extensions();
3211                        self.push_system_msg("Config and Lua extensions reloaded.");
3212                    }
3213                    Err(e) => self.push_system_msg(&format!("Reload failed: {e}")),
3214                }
3215            }
3216            "fork" => {
3217                let leaf = self.session.leaf_id().unwrap_or_default().to_string();
3218                let path = imp_core::storage::global_sessions_dir()
3219                    .join(format!("{}.jsonl", uuid::Uuid::new_v4()));
3220                match self.session.fork(&leaf, &path) {
3221                    Ok(forked) => {
3222                        self.session = forked;
3223                        self.push_system_msg("Forked. You're on a new branch.");
3224                    }
3225                    Err(e) => self.push_system_msg(&format!("Fork failed: {e}")),
3226                }
3227            }
3228            "memory" | "mem" => {
3229                self.handle_memory_command(cmd);
3230            }
3231            "checkpoints" => {
3232                let checkpoints = self.session.checkpoint_records();
3233                if checkpoints.is_empty() {
3234                    self.push_system_msg("No checkpoints recorded in this session.");
3235                } else {
3236                    let mut lines = vec![format!("{} checkpoint(s):", checkpoints.len())];
3237                    for checkpoint in checkpoints {
3238                        let label = checkpoint
3239                            .label
3240                            .as_deref()
3241                            .map(|label| format!(" — {label}"))
3242                            .unwrap_or_default();
3243                        lines.push(format!(
3244                            "- {}{} ({} file{})",
3245                            checkpoint.checkpoint_id,
3246                            label,
3247                            checkpoint.files.len(),
3248                            if checkpoint.files.len() == 1 { "" } else { "s" }
3249                        ));
3250                    }
3251                    self.push_system_msg(&lines.join("\n"));
3252                }
3253            }
3254            "restore-checkpoint" => {
3255                let needle = cmd.strip_prefix("restore-checkpoint").unwrap_or("").trim();
3256                if needle.is_empty() {
3257                    self.push_system_msg("Usage: /restore-checkpoint <checkpoint id or label>");
3258                } else {
3259                    self.restore_checkpoint_command(needle);
3260                }
3261            }
3262            "help" => {
3263                self.push_system_msg(concat!(
3264                    "Commands:\n",
3265                    "  /new        — start fresh session\n",
3266                    "  /model      — switch model\n",
3267                    "  /compact    — compress context\n",
3268                    "  /resume     — resume/search sessions\n",
3269                    "  /session    — legacy alias (defunct)\n",
3270                    "  /fork       — branch conversation\n",
3271                    "  /name <n>   — rename session\n",
3272                    "  /export [f] — export to markdown\n",
3273                    "  /copy       — copy selection or last response\n",
3274                    "  /memory     — view/edit agent memory\n",
3275                    "  /checkpoints — list recorded file checkpoints\n",
3276                    "  /restore-checkpoint <id> — inspect restore target for a checkpoint\n",
3277                    "  /reload     — reload config\n",
3278                    "  /settings   — edit settings\n",
3279                    "  /personality — customize imp personality\n",
3280                    "  /login [provider]   — OAuth login (Anthropic/OpenAI/Kimi Code)\n",
3281                    "  /secrets [provider] — save/list API keys & service secrets\n",
3282                    "  /help       — this message\n",
3283                    "\nTools: web.read supports web pages and public YouTube URLs (metadata + captions when available).\n",
3284                    "  /quit       — exit",
3285                ));
3286            }
3287            "login" => {
3288                if let Some(provider) = cmd.split_whitespace().nth(1) {
3289                    self.start_login(provider);
3290                } else {
3291                    self.open_login_picker();
3292                }
3293            }
3294            "secrets" => {
3295                if let Some(provider) = cmd.split_whitespace().nth(1) {
3296                    self.start_secrets_flow(provider);
3297                } else {
3298                    self.open_secrets_picker();
3299                }
3300            }
3301            "welcome" | "setup" => {
3302                let all_models = self.model_registry.list().to_vec();
3303                self.mode = UiMode::Welcome(WelcomeState::new(&all_models));
3304            }
3305            "copy" => {
3306                if self.copy_selection() {
3307                    return;
3308                }
3309                // Copy last assistant message to clipboard
3310                if let Some(last) = self.messages.iter().rev().find(|m| {
3311                    matches!(
3312                        m.role,
3313                        MessageRole::Assistant | MessageRole::Warning | MessageRole::Error
3314                    )
3315                }) {
3316                    let text = last.content.clone();
3317                    self.copy_to_clipboard(&text);
3318                    self.messages.push(DisplayMessage {
3319                        role: MessageRole::System,
3320                        content: "Copied to clipboard.".into(),
3321                        thinking: None,
3322                        tool_calls: Vec::new(),
3323                        assistant_blocks: Vec::new(),
3324                        is_streaming: false,
3325                        timestamp: imp_llm::now(),
3326                    });
3327                }
3328            }
3329            _ => {
3330                // Try Lua extension commands before reporting unknown
3331                if !self.try_lua_command(cmd) {
3332                    self.messages.push(DisplayMessage {
3333                        role: MessageRole::Error,
3334                        content: format!("Unknown command: /{cmd}"),
3335                        thinking: None,
3336                        tool_calls: Vec::new(),
3337                        assistant_blocks: Vec::new(),
3338                        is_streaming: false,
3339                        timestamp: imp_llm::now(),
3340                    });
3341                }
3342            }
3343        }
3344        self.editor.clear();
3345    }
3346
3347    /// Handle `/memory` subcommands.
3348    ///
3349    /// - `/memory`           — show both stores
3350    /// - `/memory add <t>`   — add entry to memory.md
3351    /// - `/memory user <t>`  — add entry to user.md
3352    /// - `/memory remove <t>` — remove matching entry from memory.md
3353    /// - `/memory remove user <t>` — remove matching entry from user.md
3354    /// - `/memory clear`     — wipe memory.md
3355    /// - `/memory clear user` — wipe user.md
3356    fn handle_memory_command(&mut self, cmd: &str) {
3357        use imp_core::memory::MemoryStore;
3358
3359        let config_dir = Config::user_config_dir();
3360        let mem_path = config_dir.join("memory.md");
3361        let user_path = config_dir.join("user.md");
3362        let mem_limit = self.config.learning.memory_char_limit;
3363        let user_limit = self.config.learning.user_char_limit;
3364
3365        // Strip the command name prefix ("memory" or "mem") to get arguments
3366        let rest = cmd
3367            .strip_prefix("memory")
3368            .or_else(|| cmd.strip_prefix("mem"))
3369            .unwrap_or("")
3370            .trim();
3371
3372        if rest.is_empty() {
3373            // Show both stores
3374            let mut output = String::new();
3375
3376            match MemoryStore::load(&mem_path, mem_limit) {
3377                Ok(store) => {
3378                    let (used, limit) = store.usage();
3379                    output.push_str(&format!("Memory ({used}/{limit} chars):\n"));
3380                    if store.entries().is_empty() {
3381                        output.push_str("  (empty)\n");
3382                    } else {
3383                        for (i, entry) in store.entries().iter().enumerate() {
3384                            output.push_str(&format!("  {}. {}\n", i + 1, entry));
3385                        }
3386                    }
3387                }
3388                Err(e) => output.push_str(&format!("Error loading memory.md: {e}\n")),
3389            }
3390
3391            output.push('\n');
3392
3393            match MemoryStore::load(&user_path, user_limit) {
3394                Ok(store) => {
3395                    let (used, limit) = store.usage();
3396                    output.push_str(&format!("User profile ({used}/{limit} chars):\n"));
3397                    if store.entries().is_empty() {
3398                        output.push_str("  (empty)\n");
3399                    } else {
3400                        for (i, entry) in store.entries().iter().enumerate() {
3401                            output.push_str(&format!("  {}. {}\n", i + 1, entry));
3402                        }
3403                    }
3404                }
3405                Err(e) => output.push_str(&format!("Error loading user.md: {e}\n")),
3406            }
3407
3408            if !self.config.learning.enabled {
3409                output.push_str("\n⚠ Learning is disabled in config. Memory won't be loaded into the system prompt.");
3410            }
3411
3412            self.push_system_msg(output.trim_end());
3413            return;
3414        }
3415
3416        let mut words = rest.splitn(2, char::is_whitespace);
3417        let sub = words.next().unwrap_or("");
3418        let arg = words.next().unwrap_or("").trim();
3419
3420        match sub {
3421            "add" => {
3422                if arg.is_empty() {
3423                    self.push_system_msg("Usage: /memory add <text>");
3424                    return;
3425                }
3426                match MemoryStore::load(&mem_path, mem_limit) {
3427                    Ok(mut store) => match store.add(arg) {
3428                        Ok(result) => {
3429                            self.push_system_msg(&format!("{} [{}]", result.message, result.usage))
3430                        }
3431                        Err(e) => self.push_system_msg(&format!("Error: {e}")),
3432                    },
3433                    Err(e) => self.push_system_msg(&format!("Error: {e}")),
3434                }
3435            }
3436            "user" => {
3437                if arg.is_empty() {
3438                    self.push_system_msg("Usage: /memory user <text>");
3439                    return;
3440                }
3441                match MemoryStore::load(&user_path, user_limit) {
3442                    Ok(mut store) => match store.add(arg) {
3443                        Ok(result) => {
3444                            self.push_system_msg(&format!("{} [{}]", result.message, result.usage))
3445                        }
3446                        Err(e) => self.push_system_msg(&format!("Error: {e}")),
3447                    },
3448                    Err(e) => self.push_system_msg(&format!("Error: {e}")),
3449                }
3450            }
3451            "remove" | "rm" => {
3452                if arg.is_empty() {
3453                    self.push_system_msg("Usage: /memory remove <text>");
3454                    return;
3455                }
3456                // Check if removing from user store: "/memory remove user <text>"
3457                if let Some(user_arg) = arg.strip_prefix("user ").map(|s| s.trim()) {
3458                    if user_arg.is_empty() {
3459                        self.push_system_msg("Usage: /memory remove user <text>");
3460                        return;
3461                    }
3462                    match MemoryStore::load(&user_path, user_limit) {
3463                        Ok(mut store) => match store.remove(user_arg) {
3464                            Ok(result) => self
3465                                .push_system_msg(&format!("{} [{}]", result.message, result.usage)),
3466                            Err(e) => self.push_system_msg(&format!("Error: {e}")),
3467                        },
3468                        Err(e) => self.push_system_msg(&format!("Error: {e}")),
3469                    }
3470                } else {
3471                    match MemoryStore::load(&mem_path, mem_limit) {
3472                        Ok(mut store) => match store.remove(arg) {
3473                            Ok(result) => self
3474                                .push_system_msg(&format!("{} [{}]", result.message, result.usage)),
3475                            Err(e) => self.push_system_msg(&format!("Error: {e}")),
3476                        },
3477                        Err(e) => self.push_system_msg(&format!("Error: {e}")),
3478                    }
3479                }
3480            }
3481            "replace" => {
3482                // "/memory replace <old> -> <new>"
3483                if let Some((old, new)) = arg.split_once("->") {
3484                    let old = old.trim();
3485                    let new = new.trim();
3486                    if old.is_empty() || new.is_empty() {
3487                        self.push_system_msg("Usage: /memory replace <old text> -> <new text>");
3488                        return;
3489                    }
3490                    match MemoryStore::load(&mem_path, mem_limit) {
3491                        Ok(mut store) => match store.replace(old, new) {
3492                            Ok(result) => self
3493                                .push_system_msg(&format!("{} [{}]", result.message, result.usage)),
3494                            Err(e) => self.push_system_msg(&format!("Error: {e}")),
3495                        },
3496                        Err(e) => self.push_system_msg(&format!("Error: {e}")),
3497                    }
3498                } else {
3499                    self.push_system_msg("Usage: /memory replace <old text> -> <new text>");
3500                }
3501            }
3502            "clear" => {
3503                let target = arg;
3504                if target == "user" {
3505                    if user_path.exists() {
3506                        match std::fs::write(&user_path, "") {
3507                            Ok(_) => self.push_system_msg("User profile cleared."),
3508                            Err(e) => self.push_system_msg(&format!("Error: {e}")),
3509                        }
3510                    } else {
3511                        self.push_system_msg("User profile is already empty.");
3512                    }
3513                } else if target.is_empty() {
3514                    if mem_path.exists() {
3515                        match std::fs::write(&mem_path, "") {
3516                            Ok(_) => self.push_system_msg("Memory cleared."),
3517                            Err(e) => self.push_system_msg(&format!("Error: {e}")),
3518                        }
3519                    } else {
3520                        self.push_system_msg("Memory is already empty.");
3521                    }
3522                } else {
3523                    self.push_system_msg("Usage: /memory clear [user]");
3524                }
3525            }
3526            "help" => {
3527                self.push_system_msg(concat!(
3528                    "Memory commands:\n",
3529                    "  /memory              — show all entries\n",
3530                    "  /memory add <text>   — add to memory\n",
3531                    "  /memory user <text>  — add to user profile\n",
3532                    "  /memory remove <text>  — remove from memory\n",
3533                    "  /memory remove user <text> — remove from user profile\n",
3534                    "  /memory replace <old> -> <new> — replace entry\n",
3535                    "  /memory clear        — clear memory\n",
3536                    "  /memory clear user   — clear user profile",
3537                ));
3538            }
3539            _ => {
3540                self.push_system_msg(&format!(
3541                    "Unknown memory subcommand: {sub}\nUse /memory help for usage."
3542                ));
3543            }
3544        }
3545    }
3546
3547    /// Reload Lua extensions: re-scan directories, re-create runtime, and update
3548    /// the stored runtime handle. Tools are not re-registered on the running
3549    /// agent (only new agents will pick them up), but commands become available
3550    /// immediately.
3551    fn reload_lua_extensions(&mut self) {
3552        let user_config_dir = Config::user_config_dir();
3553        match imp_lua::reload(&user_config_dir, Some(&self.cwd)) {
3554            Ok((rt, _exts)) => {
3555                self.lua_runtime = Some(Arc::new(Mutex::new(rt)));
3556            }
3557            Err(e) => {
3558                self.push_system_msg(&format!("Lua reload failed: {e}"));
3559                self.lua_runtime = None;
3560            }
3561        }
3562    }
3563
3564    /// Try to dispatch a slash command to a Lua extension handler.
3565    /// Returns `true` if a matching Lua command was found and executed.
3566    fn try_lua_command(&mut self, cmd: &str) -> bool {
3567        let runtime = match &self.lua_runtime {
3568            Some(rt) => Arc::clone(rt),
3569            None => return false,
3570        };
3571
3572        let guard = match runtime.lock() {
3573            Ok(g) => g,
3574            Err(_) => return false,
3575        };
3576
3577        // Find a command matching the typed name (first word)
3578        let cmd_name = cmd.split_whitespace().next().unwrap_or(cmd);
3579        let args = cmd.strip_prefix(cmd_name).unwrap_or("").trim();
3580
3581        if !guard.has_command(cmd_name) {
3582            return false;
3583        }
3584
3585        // Execute via LuaRuntime's helper (keeps mlua types internal)
3586        let result = guard.execute_command(cmd_name, args);
3587        drop(guard);
3588
3589        match result {
3590            Ok(Some(text)) => self.push_system_msg(&text),
3591            Ok(None) => {} // Command executed silently
3592            Err(e) => self.push_system_msg(&format!("Lua command error: {e}")),
3593        }
3594        true
3595    }
3596
3597    fn start_secrets_flow(&mut self, provider: &str) {
3598        self.mode = UiMode::Normal;
3599        self.secrets_flow = Some(SecretsFlowState::AwaitingFieldNames {
3600            provider: provider.to_string(),
3601        });
3602        let (tx, _rx) = tokio::sync::oneshot::channel();
3603        self.begin_ask(
3604            crate::views::ask_bar::AskState::new(
3605                format!(
3606                    "{}\n\nField names (comma-separated) [api_key]:",
3607                    prompt_text_for_secret_provider(provider)
3608                ),
3609                String::new(),
3610                vec![],
3611                false,
3612            ),
3613            AskReply::Input(tx),
3614        );
3615    }
3616
3617    fn start_login(&mut self, provider: &str) {
3618        if !oauth_provider(provider) {
3619            self.push_error_msg(&format!(
3620                "/login {provider} is OAuth-only. Use /secrets {provider} for API keys/secrets."
3621            ));
3622            return;
3623        }
3624
3625        let status_message = match provider {
3626            "anthropic" => "Opening browser for Anthropic login...",
3627            "openai" | "openai-codex" => "Opening browser for OpenAI / ChatGPT login...",
3628            "kimi-code" => "Opening browser for Kimi Code login...",
3629            _ => {
3630                self.messages.push(DisplayMessage {
3631                    role: MessageRole::Error,
3632                    content: format!(
3633                        "OAuth login for '{provider}' not supported. Use /secrets {provider} for API keys."
3634                    ),
3635                    thinking: None,
3636                    tool_calls: Vec::new(),
3637                    assistant_blocks: Vec::new(),
3638                    is_streaming: false,
3639                    timestamp: imp_llm::now(),
3640                });
3641                return;
3642            }
3643        };
3644
3645        self.mode = UiMode::Normal;
3646        self.push_system_msg(status_message);
3647
3648        let auth_path = imp_core::storage::global_auth_path();
3649        let provider = provider.to_string();
3650        let task = tokio::spawn(async move {
3651            let login_result = match provider.as_str() {
3652                "anthropic" => {
3653                    imp_llm::oauth::anthropic::AnthropicOAuth::new()
3654                        .login(
3655                            |url| {
3656                                open_url(url);
3657                            },
3658                            || async { None },
3659                        )
3660                        .await
3661                }
3662                "openai" | "openai-codex" => {
3663                    imp_llm::oauth::chatgpt::ChatGptOAuth::new()
3664                        .login(
3665                            |url| {
3666                                open_url(url);
3667                            },
3668                            || async { None },
3669                        )
3670                        .await
3671                }
3672                "kimi-code" => {
3673                    imp_llm::oauth::kimi_code::KimiCodeOAuth::new()
3674                        .login(
3675                            |url| {
3676                                open_url(url);
3677                            },
3678                            |_msg| {
3679                                // Messages are silently dropped in the TUI background task;
3680                                // the browser URL is the primary signal.
3681                            },
3682                        )
3683                        .await
3684                }
3685                _ => unreachable!(),
3686            };
3687
3688            match login_result {
3689                Ok(credential) => {
3690                    let success_message = imp_llm::auth::oauth_display_info_for_credential(
3691                        provider.as_str(),
3692                        &credential,
3693                    )
3694                    .map(|info| info.login_message(provider.as_str()))
3695                    .unwrap_or_else(|| format!("Logged in to {} successfully.", provider));
3696
3697                    let mut store = AuthStore::load(&auth_path)
3698                        .unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
3699                    match provider.as_str() {
3700                        "anthropic" => {
3701                            let _ = store.store(
3702                                "anthropic",
3703                                imp_llm::auth::StoredCredential::OAuth(credential),
3704                            );
3705                        }
3706                        "openai" | "openai-codex" => {
3707                            let _ = store.store(
3708                                "openai",
3709                                imp_llm::auth::StoredCredential::OAuth(credential.clone()),
3710                            );
3711                            let _ = store.store(
3712                                "openai-codex",
3713                                imp_llm::auth::StoredCredential::OAuth(credential),
3714                            );
3715                        }
3716                        "kimi-code" => {
3717                            let _ = store.store(
3718                                "kimi-code",
3719                                imp_llm::auth::StoredCredential::OAuth(credential),
3720                            );
3721                        }
3722                        _ => {}
3723                    }
3724                    LoginTaskExit::Success(success_message)
3725                }
3726                Err(e) => LoginTaskExit::Failed(format!("OAuth login failed: {e}")),
3727            }
3728        });
3729        self.login_task = Some(task);
3730    }
3731
3732    fn open_secrets_picker(&mut self) {
3733        let auth_path = imp_core::storage::global_auth_path();
3734        let auth_store =
3735            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
3736        let providers = secret_providers(&ProviderRegistry::with_builtins())
3737            .into_iter()
3738            .map(|mut provider| {
3739                provider.configured = provider_logged_in(&auth_store, &provider.id);
3740                provider
3741            })
3742            .collect();
3743        self.mode = UiMode::SecretsPicker(SecretsPickerState::new(providers));
3744    }
3745
3746    fn open_login_picker(&mut self) {
3747        let auth_path = imp_core::storage::global_auth_path();
3748        let auth_store =
3749            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
3750        let providers = login_providers(&ProviderRegistry::with_builtins())
3751            .into_iter()
3752            .filter(|provider| oauth_provider(provider.id))
3753            .map(|mut provider| {
3754                provider.logged_in = provider_logged_in(&auth_store, provider.id);
3755                provider
3756            })
3757            .collect();
3758        self.mode = UiMode::LoginPicker(LoginPickerState::new(providers));
3759    }
3760
3761    fn open_settings(&mut self) {
3762        let models = self.filtered_models();
3763        let auth_path = imp_core::storage::global_auth_path();
3764        let auth_store =
3765            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
3766        let state = SettingsState::new(&self.config, &self.model_name, &models, &auth_store);
3767        self.mode = UiMode::Settings(state);
3768    }
3769
3770    fn open_personality(&mut self) {
3771        let user_config_dir = Config::user_config_dir();
3772        let global_path = user_config_dir.join("soul.md");
3773        let project_soul = imp_core::resources::discover_project_soul(&self.cwd);
3774        let project_path = project_soul
3775            .as_ref()
3776            .map(|soul| soul.path.clone())
3777            .unwrap_or_else(|| imp_core::resources::suggested_project_soul_path(&self.cwd));
3778        let scope = if project_soul.is_some() {
3779            PersonalityScope::Project
3780        } else {
3781            PersonalityScope::Global
3782        };
3783        let state = PersonalityState::from_paths(global_path, project_path, scope);
3784        self.mode = UiMode::Personality(state);
3785    }
3786
3787    fn handle_session_picker_key(&mut self, key: KeyEvent) {
3788        match key.code {
3789            KeyCode::Esc => {
3790                self.mode = UiMode::Normal;
3791            }
3792            KeyCode::Up | KeyCode::Char('k') => {
3793                if let UiMode::SessionPicker(ref mut state) = self.mode {
3794                    state.move_up();
3795                }
3796            }
3797            KeyCode::Down | KeyCode::Char('j') => {
3798                if let UiMode::SessionPicker(ref mut state) = self.mode {
3799                    state.move_down();
3800                }
3801            }
3802            KeyCode::Backspace => {
3803                if let UiMode::SessionPicker(ref mut state) = self.mode {
3804                    state.pop_filter();
3805                }
3806            }
3807            KeyCode::Char(c) if !c.is_control() => {
3808                if let UiMode::SessionPicker(ref mut state) = self.mode {
3809                    state.push_filter(c);
3810                }
3811            }
3812            KeyCode::Enter => {
3813                let selected_path = if let UiMode::SessionPicker(ref state) = self.mode {
3814                    state.selected_session().map(|s| s.path.clone())
3815                } else {
3816                    None
3817                };
3818                self.mode = UiMode::Normal;
3819                if let Some(path) = selected_path {
3820                    match SessionManager::open(&path) {
3821                        Ok(session) => {
3822                            self.session = session;
3823                            self.load_session_messages();
3824                            if let Some(summary) = self.session.summary() {
3825                                self.messages.push(DisplayMessage {
3826                                    role: MessageRole::System,
3827                                    content: format!("Session resumed — {}", summary),
3828                                    thinking: None,
3829                                    tool_calls: Vec::new(),
3830                                    assistant_blocks: Vec::new(),
3831                                    is_streaming: false,
3832                                    timestamp: imp_llm::now(),
3833                                });
3834                            } else {
3835                                self.messages.push(DisplayMessage {
3836                                    role: MessageRole::System,
3837                                    content: "Session resumed.".into(),
3838                                    thinking: None,
3839                                    tool_calls: Vec::new(),
3840                                    assistant_blocks: Vec::new(),
3841                                    is_streaming: false,
3842                                    timestamp: imp_llm::now(),
3843                                });
3844                            }
3845                        }
3846                        Err(e) => {
3847                            self.messages.push(DisplayMessage {
3848                                role: MessageRole::Error,
3849                                content: format!("Failed to open session: {e}"),
3850                                thinking: None,
3851                                tool_calls: Vec::new(),
3852                                assistant_blocks: Vec::new(),
3853                                is_streaming: false,
3854                                timestamp: imp_llm::now(),
3855                            });
3856                        }
3857                    }
3858                }
3859            }
3860            _ => {}
3861        }
3862    }
3863
3864    fn handle_ask_key(&mut self, key: KeyEvent) {
3865        if self.is_paste_shortcut(key) {
3866            self.paste_from_clipboard();
3867            return;
3868        }
3869
3870        let Some(state) = self.ask_state.as_ref() else {
3871            return;
3872        };
3873
3874        match key.code {
3875            KeyCode::Esc => {
3876                self.cancel_ask();
3877            }
3878            KeyCode::Enter => {
3879                self.sync_ask_from_editor();
3880                self.finish_ask();
3881            }
3882            KeyCode::Tab => {
3883                let replacement = if !state.options.is_empty() && !state.input_active {
3884                    state.options.get(state.cursor).map(|opt| opt.label.clone())
3885                } else {
3886                    None
3887                };
3888                if let Some(text) = replacement {
3889                    self.editor.set_content(&text);
3890                    self.sync_ask_from_editor();
3891                }
3892            }
3893            KeyCode::Char(' ') if !state.input_active => {
3894                if let Some(state) = self.ask_state.as_mut() {
3895                    state.toggle_current();
3896                }
3897            }
3898            KeyCode::Char(c) if !state.input_active && c.is_ascii_digit() => {
3899                let n = c.to_digit(10).unwrap_or(0) as usize;
3900                let quick_selected = if let Some(state) = self.ask_state.as_mut() {
3901                    state.quick_select(n)
3902                } else {
3903                    false
3904                };
3905                if quick_selected {
3906                    self.finish_ask();
3907                }
3908            }
3909            KeyCode::Up => {
3910                if let Some(state) = self.ask_state.as_mut() {
3911                    if state.input_active {
3912                        if !self.editor.move_up() {
3913                            self.editor.move_home();
3914                        }
3915                        self.sync_ask_from_editor();
3916                    } else {
3917                        state.cursor_up();
3918                    }
3919                }
3920            }
3921            KeyCode::Down => {
3922                if let Some(state) = self.ask_state.as_mut() {
3923                    if state.input_active {
3924                        if !self.editor.move_down() {
3925                            self.editor.move_end();
3926                        }
3927                        self.sync_ask_from_editor();
3928                    } else {
3929                        state.cursor_down();
3930                    }
3931                }
3932            }
3933            _ => {
3934                if let Some(action) = keybindings::resolve_normal(key) {
3935                    match action {
3936                        Action::InsertChar(c) => self.editor.insert_char(c),
3937                        Action::Backspace => self.editor.delete_back(),
3938                        Action::Delete => self.editor.delete_forward(),
3939                        Action::CursorLeft => self.editor.move_left(),
3940                        Action::CursorRight => self.editor.move_right(),
3941                        Action::CursorHome => self.editor.move_home(),
3942                        Action::CursorEnd => self.editor.move_end(),
3943                        Action::WordLeft => self.editor.move_word_left(),
3944                        Action::WordRight => self.editor.move_word_right(),
3945                        Action::DeleteWordBack => self.editor.delete_word_back(),
3946                        Action::DeleteToStart => self.editor.delete_to_start(),
3947                        Action::DeleteToEnd => self.editor.delete_to_end(),
3948                        Action::NewLine => self.editor.insert_newline(),
3949                        _ => {}
3950                    }
3951                    self.sync_ask_from_editor();
3952                }
3953            }
3954        }
3955    }
3956
3957    fn finish_ask(&mut self) {
3958        use crate::views::ask_bar::AskResult;
3959
3960        self.sync_ask_from_editor();
3961        let state = self.ask_state.take();
3962        let reply = self.ask_reply.take();
3963
3964        let Some(state) = state else { return };
3965        let result = state.confirm();
3966        self.restore_editor_after_ask();
3967
3968        // Show Q&A in chat as user-style messages so they stay visually distinct
3969        // (System messages render muted/grey which makes them look faded.)
3970        self.messages.push(DisplayMessage {
3971            role: MessageRole::User,
3972            content: state.question.clone(),
3973            thinking: None,
3974            tool_calls: Vec::new(),
3975            assistant_blocks: Vec::new(),
3976            is_streaming: false,
3977            timestamp: imp_llm::now(),
3978        });
3979
3980        match (&result, reply) {
3981            (AskResult::Text(text), Some(AskReply::Input(tx))) => {
3982                self.messages.push(DisplayMessage {
3983                    role: MessageRole::User,
3984                    content: text.clone(),
3985                    thinking: None,
3986                    tool_calls: Vec::new(),
3987                    assistant_blocks: Vec::new(),
3988                    is_streaming: false,
3989                    timestamp: imp_llm::now(),
3990                });
3991                self.invalidate_chat_render_cache();
3992                let _ = tx.send(Some(text.clone()));
3993                self.advance_secrets_flow(Some(text.clone()));
3994            }
3995            (AskResult::Selected(indices), Some(AskReply::Select(tx))) => {
3996                let labels: Vec<String> = indices
3997                    .iter()
3998                    .filter_map(|&i| state.options.get(i).map(|o| o.label.clone()))
3999                    .collect();
4000                self.messages.push(DisplayMessage {
4001                    role: MessageRole::User,
4002                    content: labels.join(", "),
4003                    thinking: None,
4004                    tool_calls: Vec::new(),
4005                    assistant_blocks: Vec::new(),
4006                    is_streaming: false,
4007                    timestamp: imp_llm::now(),
4008                });
4009                self.invalidate_chat_render_cache();
4010                // Send first selected index for single select
4011                let _ = tx.send(indices.first().copied());
4012            }
4013            (AskResult::Text(text), Some(AskReply::Select(tx))) => {
4014                // User typed custom text on a Select ask.
4015                // Find if the text matches an option label (case-insensitive).
4016                let match_idx = state
4017                    .options
4018                    .iter()
4019                    .position(|o| o.label.eq_ignore_ascii_case(text));
4020                if let Some(idx) = match_idx {
4021                    self.messages.push(DisplayMessage {
4022                        role: MessageRole::User,
4023                        content: state.options[idx].label.clone(),
4024                        thinking: None,
4025                        tool_calls: Vec::new(),
4026                        assistant_blocks: Vec::new(),
4027                        is_streaming: false,
4028                        timestamp: imp_llm::now(),
4029                    });
4030                    self.invalidate_chat_render_cache();
4031                    let _ = tx.send(Some(idx));
4032                } else {
4033                    // No match — send None. The ask tool will get "User cancelled".
4034                    self.messages.push(DisplayMessage {
4035                        role: MessageRole::User,
4036                        content: text.clone(),
4037                        thinking: None,
4038                        tool_calls: Vec::new(),
4039                        assistant_blocks: Vec::new(),
4040                        is_streaming: false,
4041                        timestamp: imp_llm::now(),
4042                    });
4043                    self.invalidate_chat_render_cache();
4044                    let _ = tx.send(None);
4045                }
4046            }
4047            _ => {}
4048        }
4049    }
4050
4051    fn advance_secrets_flow(&mut self, input: Option<String>) {
4052        let Some(flow) = self.secrets_flow.take() else {
4053            return;
4054        };
4055
4056        match flow {
4057            SecretsFlowState::AwaitingFieldNames { provider } => {
4058                let field_names = parse_secret_field_names(input.as_deref().unwrap_or(""));
4059                let first_field = field_names
4060                    .first()
4061                    .cloned()
4062                    .unwrap_or_else(|| "api_key".into());
4063                self.secrets_flow = Some(SecretsFlowState::AwaitingFieldValues {
4064                    provider,
4065                    fields: field_names,
4066                    current: 0,
4067                    values: HashMap::new(),
4068                });
4069                let (tx, _rx) = tokio::sync::oneshot::channel();
4070                self.begin_ask(
4071                    crate::views::ask_bar::AskState::new(
4072                        format!("Enter {first_field}:"),
4073                        String::new(),
4074                        vec![],
4075                        false,
4076                    ),
4077                    AskReply::Input(tx),
4078                );
4079            }
4080            SecretsFlowState::AwaitingFieldValues {
4081                provider,
4082                fields,
4083                current,
4084                mut values,
4085            } => {
4086                let Some(value) = input.filter(|value| !value.trim().is_empty()) else {
4087                    self.push_error_msg("Secret entry cancelled.");
4088                    return;
4089                };
4090
4091                let field = fields
4092                    .get(current)
4093                    .cloned()
4094                    .unwrap_or_else(|| "api_key".into());
4095                values.insert(field, value.trim().to_string());
4096
4097                if current + 1 < fields.len() {
4098                    let next_field = fields[current + 1].clone();
4099                    self.secrets_flow = Some(SecretsFlowState::AwaitingFieldValues {
4100                        provider: provider.clone(),
4101                        fields: fields.clone(),
4102                        current: current + 1,
4103                        values,
4104                    });
4105                    let (tx, _rx) = tokio::sync::oneshot::channel();
4106                    self.begin_ask(
4107                        crate::views::ask_bar::AskState::new(
4108                            format!("Enter {next_field}:"),
4109                            String::new(),
4110                            vec![],
4111                            false,
4112                        ),
4113                        AskReply::Input(tx),
4114                    );
4115                    return;
4116                }
4117
4118                let auth_path = imp_core::storage::global_auth_path();
4119                let mut auth_store = AuthStore::load(&auth_path)
4120                    .unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
4121                match auth_store.store_secret_fields(&provider, values) {
4122                    Ok(()) => {
4123                        self.push_system_msg(&format!("Saved secure secrets for {provider}."))
4124                    }
4125                    Err(e) => {
4126                        self.push_error_msg(&format!("Failed to save secrets for {provider}: {e}"))
4127                    }
4128                }
4129            }
4130        }
4131    }
4132
4133    fn cancel_ask(&mut self) {
4134        self.secrets_flow = None;
4135        self.ask_state = None;
4136        self.restore_editor_after_ask();
4137        if let Some(reply) = self.ask_reply.take() {
4138            match reply {
4139                AskReply::Select(tx) => {
4140                    let _ = tx.send(None);
4141                }
4142                AskReply::Input(tx) => {
4143                    let _ = tx.send(None);
4144                }
4145            }
4146        }
4147        // Stop the agent — user wants control back
4148        if let Some(ref handle) = self.agent_handle {
4149            let _ = handle.command_tx.try_send(AgentCommand::Cancel);
4150        }
4151        self.is_streaming = false;
4152    }
4153
4154    fn handle_settings_key(&mut self, key: KeyEvent) {
4155        use crate::views::settings::SettingsField;
4156        use crossterm::event::KeyCode;
4157
4158        match key.code {
4159            KeyCode::Esc => {
4160                // Commit any pending edit, then dismiss
4161                if let UiMode::Settings(ref mut state) = self.mode {
4162                    state.commit_edit();
4163                }
4164                self.mode = UiMode::Normal;
4165            }
4166            KeyCode::Up => {
4167                if let UiMode::Settings(ref mut state) = self.mode {
4168                    state.move_up();
4169                }
4170            }
4171            KeyCode::Down => {
4172                if let UiMode::Settings(ref mut state) = self.mode {
4173                    state.move_down();
4174                }
4175            }
4176            KeyCode::Left => {
4177                if let UiMode::Settings(ref mut state) = self.mode {
4178                    state.cycle_backward();
4179                }
4180            }
4181            KeyCode::Right => {
4182                if let UiMode::Settings(ref mut state) = self.mode {
4183                    state.cycle_forward();
4184                }
4185            }
4186            KeyCode::Enter => {
4187                let is_save = matches!(
4188                    &self.mode,
4189                    UiMode::Settings(s) if s.current_field() == SettingsField::Save
4190                );
4191                if is_save {
4192                    self.save_settings();
4193                } else if let UiMode::Settings(ref mut state) = self.mode {
4194                    state.start_edit();
4195                }
4196            }
4197            KeyCode::Backspace => {
4198                if let UiMode::Settings(ref mut state) = self.mode {
4199                    state.pop_char();
4200                }
4201            }
4202            KeyCode::Char(c) => {
4203                if let UiMode::Settings(ref mut state) = self.mode {
4204                    state.push_char(c);
4205                }
4206            }
4207            _ => {}
4208        }
4209    }
4210
4211    fn handle_personality_key(&mut self, key: KeyEvent) {
4212        match key.code {
4213            KeyCode::Esc => {
4214                if let UiMode::Personality(ref mut state) = self.mode {
4215                    if state.pending_overwrite.is_some() {
4216                        state.cancel_overwrite();
4217                    } else {
4218                        self.mode = UiMode::Normal;
4219                    }
4220                }
4221            }
4222            KeyCode::Tab => {
4223                if let UiMode::Personality(ref mut state) = self.mode {
4224                    state.switch_tab();
4225                }
4226            }
4227            KeyCode::Up => {
4228                if let UiMode::Personality(ref mut state) = self.mode {
4229                    match state.tab {
4230                        crate::views::personality::PersonalityTab::Builder => state.move_up(),
4231                        crate::views::personality::PersonalityTab::Source => {
4232                            state.editor.move_up();
4233                        }
4234                    }
4235                }
4236            }
4237            KeyCode::Down => {
4238                if let UiMode::Personality(ref mut state) = self.mode {
4239                    match state.tab {
4240                        crate::views::personality::PersonalityTab::Builder => state.move_down(),
4241                        crate::views::personality::PersonalityTab::Source => {
4242                            state.editor.move_down();
4243                        }
4244                    }
4245                }
4246            }
4247            KeyCode::Left => {
4248                if let UiMode::Personality(ref mut state) = self.mode {
4249                    match state.tab {
4250                        crate::views::personality::PersonalityTab::Builder => {
4251                            state.cycle_backward()
4252                        }
4253                        crate::views::personality::PersonalityTab::Source => state.move_left(),
4254                    }
4255                }
4256            }
4257            KeyCode::Right => {
4258                if let UiMode::Personality(ref mut state) = self.mode {
4259                    match state.tab {
4260                        crate::views::personality::PersonalityTab::Builder => state.cycle_forward(),
4261                        crate::views::personality::PersonalityTab::Source => state.move_right(),
4262                    }
4263                }
4264            }
4265            KeyCode::Enter => {
4266                let should_save = matches!(&self.mode, UiMode::Personality(s) if s.pending_overwrite.is_none() && matches!(s.tab, crate::views::personality::PersonalityTab::Builder) && matches!(s.current_field(), crate::views::personality::PersonalityField::Save));
4267                if should_save {
4268                    self.save_personality();
4269                } else if let UiMode::Personality(ref mut state) = self.mode {
4270                    if state.pending_overwrite.is_some() {
4271                        state.confirm_overwrite();
4272                    } else {
4273                        match state.tab {
4274                            crate::views::personality::PersonalityTab::Builder => {
4275                                state.cycle_forward()
4276                            }
4277                            crate::views::personality::PersonalityTab::Source => {
4278                                state.insert_newline()
4279                            }
4280                        }
4281                    }
4282                }
4283            }
4284            KeyCode::Backspace => {
4285                if let UiMode::Personality(ref mut state) = self.mode {
4286                    if state.pending_overwrite.is_none()
4287                        && matches!(state.tab, crate::views::personality::PersonalityTab::Source)
4288                    {
4289                        state.pop_char();
4290                    }
4291                }
4292            }
4293            KeyCode::Char('y') | KeyCode::Char('Y') => {
4294                if let UiMode::Personality(ref mut state) = self.mode {
4295                    if state.pending_overwrite.is_some() {
4296                        state.confirm_overwrite();
4297                    } else if matches!(state.tab, crate::views::personality::PersonalityTab::Source)
4298                    {
4299                        if let KeyCode::Char(c) = key.code {
4300                            state.insert_char(c);
4301                        }
4302                    }
4303                }
4304            }
4305            KeyCode::Char('n') | KeyCode::Char('N') => {
4306                if let UiMode::Personality(ref mut state) = self.mode {
4307                    if state.pending_overwrite.is_some() {
4308                        state.cancel_overwrite();
4309                    } else if matches!(state.tab, crate::views::personality::PersonalityTab::Source)
4310                    {
4311                        if let KeyCode::Char(c) = key.code {
4312                            state.insert_char(c);
4313                        }
4314                    }
4315                }
4316            }
4317            KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4318                self.save_personality();
4319            }
4320            KeyCode::Char(c) => {
4321                if let UiMode::Personality(ref mut state) = self.mode {
4322                    if state.pending_overwrite.is_none()
4323                        && matches!(state.tab, crate::views::personality::PersonalityTab::Source)
4324                    {
4325                        state.insert_char(c);
4326                    }
4327                }
4328            }
4329            _ => {}
4330        }
4331    }
4332
4333    fn handle_welcome_key(&mut self, key: KeyEvent) {
4334        let step = match &self.mode {
4335            UiMode::Welcome(s) => s.current_step(),
4336            _ => return,
4337        };
4338
4339        match step {
4340            WelcomeStep::Welcome => match key.code {
4341                KeyCode::Enter => {
4342                    if let UiMode::Welcome(ref mut state) = self.mode {
4343                        state.advance();
4344                    }
4345                }
4346                KeyCode::Esc => {
4347                    self.mode = UiMode::Normal;
4348                }
4349                _ => {}
4350            },
4351            WelcomeStep::ProviderAuth => match key.code {
4352                KeyCode::Up => {
4353                    if let UiMode::Welcome(ref mut state) = self.mode {
4354                        state.provider_up();
4355                        let all_models = self.model_registry.list().to_vec();
4356                        state.update_models(&all_models);
4357                    }
4358                }
4359                KeyCode::Down => {
4360                    if let UiMode::Welcome(ref mut state) = self.mode {
4361                        state.provider_down();
4362                        let all_models = self.model_registry.list().to_vec();
4363                        state.update_models(&all_models);
4364                    }
4365                }
4366                KeyCode::Enter => {
4367                    let auth_result = if let UiMode::Welcome(ref mut state) = self.mode {
4368                        state.check_auth_resolved()
4369                    } else {
4370                        Ok(())
4371                    };
4372                    match auth_result {
4373                        Ok(()) => {
4374                            if let UiMode::Welcome(ref mut state) = self.mode {
4375                                state.advance();
4376                            }
4377                        }
4378                        Err(error) => {
4379                            self.messages.push(DisplayMessage {
4380                                role: MessageRole::Error,
4381                                content: error,
4382                                thinking: None,
4383                                tool_calls: Vec::new(),
4384                                assistant_blocks: Vec::new(),
4385                                is_streaming: false,
4386                                timestamp: imp_llm::now(),
4387                            });
4388                        }
4389                    }
4390                }
4391                KeyCode::Esc => {
4392                    if let UiMode::Welcome(ref mut state) = self.mode {
4393                        state.go_back();
4394                    }
4395                }
4396                KeyCode::Backspace => {
4397                    if let UiMode::Welcome(ref mut state) = self.mode {
4398                        state.pop_key_char();
4399                    }
4400                }
4401                KeyCode::Char(c) => {
4402                    if let UiMode::Welcome(ref mut state) = self.mode {
4403                        state.push_key_char(c);
4404                    }
4405                }
4406                _ => {}
4407            },
4408            WelcomeStep::ModelThinking => match key.code {
4409                KeyCode::Up => {
4410                    if let UiMode::Welcome(ref mut state) = self.mode {
4411                        state.model_up();
4412                    }
4413                }
4414                KeyCode::Down => {
4415                    if let UiMode::Welcome(ref mut state) = self.mode {
4416                        state.model_down();
4417                    }
4418                }
4419                KeyCode::Right => {
4420                    if let UiMode::Welcome(ref mut state) = self.mode {
4421                        state.cycle_thinking();
4422                    }
4423                }
4424                KeyCode::Left => {
4425                    if let UiMode::Welcome(ref mut state) = self.mode {
4426                        state.cycle_thinking_back();
4427                    }
4428                }
4429                KeyCode::Enter => {
4430                    if let UiMode::Welcome(ref mut state) = self.mode {
4431                        state.advance();
4432                    }
4433                }
4434                KeyCode::Esc => {
4435                    if let UiMode::Welcome(ref mut state) = self.mode {
4436                        state.go_back();
4437                    }
4438                }
4439                _ => {}
4440            },
4441            WelcomeStep::WebSearch => match key.code {
4442                KeyCode::Up => {
4443                    if let UiMode::Welcome(ref mut state) = self.mode {
4444                        state.web_provider_up();
4445                    }
4446                }
4447                KeyCode::Down => {
4448                    if let UiMode::Welcome(ref mut state) = self.mode {
4449                        state.web_provider_down();
4450                    }
4451                }
4452                KeyCode::Enter => {
4453                    let web_result = if let UiMode::Welcome(ref mut state) = self.mode {
4454                        state.check_web_auth_resolved()
4455                    } else {
4456                        Ok(())
4457                    };
4458                    match web_result {
4459                        Ok(()) => {
4460                            self.finish_welcome();
4461                        }
4462                        Err(error) => {
4463                            self.messages.push(DisplayMessage {
4464                                role: MessageRole::Error,
4465                                content: error,
4466                                thinking: None,
4467                                tool_calls: Vec::new(),
4468                                assistant_blocks: Vec::new(),
4469                                is_streaming: false,
4470                                timestamp: imp_llm::now(),
4471                            });
4472                        }
4473                    }
4474                }
4475                KeyCode::Esc => {
4476                    if let UiMode::Welcome(ref mut state) = self.mode {
4477                        state.go_back();
4478                    }
4479                }
4480                KeyCode::Backspace => {
4481                    if let UiMode::Welcome(ref mut state) = self.mode {
4482                        state.pop_web_key_char();
4483                    }
4484                }
4485                KeyCode::Char(c) => {
4486                    if let UiMode::Welcome(ref mut state) = self.mode {
4487                        state.push_web_key_char(c);
4488                    }
4489                }
4490                _ => {}
4491            },
4492            WelcomeStep::Done => match key.code {
4493                KeyCode::Enter | KeyCode::Esc => {
4494                    self.mode = UiMode::Normal;
4495                }
4496                _ => {}
4497            },
4498        }
4499    }
4500
4501    /// Persist welcome flow choices to config and auth, then advance to Done step.
4502    fn finish_welcome(&mut self) {
4503        let (
4504            model_id,
4505            thinking,
4506            provider_id,
4507            resolved_key,
4508            resolved_web_provider,
4509            resolved_web_key,
4510        ) = match &self.mode {
4511            UiMode::Welcome(state) => {
4512                let model_id = state
4513                    .selected_model()
4514                    .map(|m| m.id.clone())
4515                    .unwrap_or_else(|| "claude-sonnet-4-6".to_string());
4516                let thinking = state.thinking_level;
4517                let provider_id = state
4518                    .selected_provider_id()
4519                    .unwrap_or("anthropic")
4520                    .to_string();
4521                let resolved_key = state.resolved_key.clone();
4522                let resolved_web_provider = state.resolved_web_provider.clone();
4523                let resolved_web_key = state.resolved_web_key.clone();
4524                (
4525                    model_id,
4526                    thinking,
4527                    provider_id,
4528                    resolved_key,
4529                    resolved_web_provider,
4530                    resolved_web_key,
4531                )
4532            }
4533            _ => return,
4534        };
4535
4536        // Update in-session config
4537        self.config.model = Some(model_id.clone());
4538        self.config.thinking = Some(thinking);
4539        self.model_name = model_id;
4540        self.thinking_level = thinking;
4541
4542        if let Some(meta) = self.model_registry.resolve_meta(&self.model_name, None) {
4543            self.context_window = meta.context_window;
4544        }
4545
4546        if let Some(web_provider) = resolved_web_provider
4547            .as_deref()
4548            .filter(|provider| *provider != "none")
4549        {
4550            self.config.web.search_provider = match web_provider {
4551                "tavily" => Some(imp_core::tools::web::types::SearchProvider::Tavily),
4552                "exa" => Some(imp_core::tools::web::types::SearchProvider::Exa),
4553                "linkup" => Some(imp_core::tools::web::types::SearchProvider::Linkup),
4554                "perplexity" => Some(imp_core::tools::web::types::SearchProvider::Perplexity),
4555                _ => self.config.web.search_provider,
4556            };
4557            std::env::set_var("IMP_WEB_PROVIDER", web_provider);
4558        }
4559
4560        // Save config.toml
4561        let config_path = imp_core::storage::global_config_path();
4562        if let Err(e) = self.config.save(&config_path) {
4563            self.messages.push(DisplayMessage {
4564                role: MessageRole::Error,
4565                content: format!("Failed to save config: {e}"),
4566                thinking: None,
4567                tool_calls: Vec::new(),
4568                assistant_blocks: Vec::new(),
4569                is_streaming: false,
4570                timestamp: imp_llm::now(),
4571            });
4572        }
4573
4574        let auth_path = imp_core::storage::global_auth_path();
4575        let mut auth_store =
4576            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
4577
4578        // Save API key if one was manually entered
4579        if let Some(key) = resolved_key {
4580            if let Err(e) = auth_store.store(
4581                &provider_id,
4582                imp_llm::auth::StoredCredential::ApiKey { key },
4583            ) {
4584                self.messages.push(DisplayMessage {
4585                    role: MessageRole::Error,
4586                    content: format!("Failed to save API key: {e}"),
4587                    thinking: None,
4588                    tool_calls: Vec::new(),
4589                    assistant_blocks: Vec::new(),
4590                    is_streaming: false,
4591                    timestamp: imp_llm::now(),
4592                });
4593            }
4594        }
4595
4596        if let (Some(web_provider), Some(web_key)) = (
4597            resolved_web_provider
4598                .as_deref()
4599                .filter(|provider| *provider != "none"),
4600            resolved_web_key,
4601        ) {
4602            if let Err(e) = auth_store.store(
4603                web_provider,
4604                imp_llm::auth::StoredCredential::ApiKey { key: web_key },
4605            ) {
4606                self.messages.push(DisplayMessage {
4607                    role: MessageRole::Error,
4608                    content: format!("Failed to save web API key: {e}"),
4609                    thinking: None,
4610                    tool_calls: Vec::new(),
4611                    assistant_blocks: Vec::new(),
4612                    is_streaming: false,
4613                    timestamp: imp_llm::now(),
4614                });
4615            }
4616        }
4617
4618        // Advance to Done screen
4619        if let UiMode::Welcome(ref mut state) = self.mode {
4620            state.advance();
4621        }
4622    }
4623
4624    fn save_personality(&mut self) {
4625        let state = match &self.mode {
4626            UiMode::Personality(state) => state.clone(),
4627            _ => return,
4628        };
4629
4630        let path = state.current_path().clone();
4631        if let Some(parent) = path.parent() {
4632            if let Err(e) = std::fs::create_dir_all(parent) {
4633                self.push_error_msg(&format!("Failed to create soul directory: {e}"));
4634                return;
4635            }
4636        }
4637
4638        let content = if state.editor.is_empty() {
4639            default_soul_markdown()
4640        } else {
4641            state.editor.content().to_string()
4642        };
4643
4644        match std::fs::write(&path, content) {
4645            Ok(()) => {
4646                if let UiMode::Personality(ref mut current) = self.mode {
4647                    current.save_success();
4648                }
4649                self.push_system_msg(&format!("Soul saved to {}", path.display()));
4650            }
4651            Err(e) => self.push_error_msg(&format!("Failed to save soul: {e}")),
4652        }
4653    }
4654
4655    fn save_settings(&mut self) {
4656        // Extract state before mutating self
4657        let state = match &self.mode {
4658            UiMode::Settings(s) => s.clone(),
4659            _ => return,
4660        };
4661
4662        // Apply to in-session config
4663        state.apply_to_config(&mut self.config);
4664        self.model_name = state.model.clone();
4665        self.thinking_level = state.thinking_level;
4666        self.theme = Theme::named(self.config.theme.as_deref().unwrap_or("default"));
4667
4668        // Update context window from registry
4669        if let Some(meta) = self.model_registry.resolve_meta(&self.model_name, None) {
4670            self.context_window = meta.context_window;
4671        }
4672
4673        let auth_path = imp_core::storage::global_auth_path();
4674        let mut auth_store =
4675            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
4676        let mut auth_notes = Vec::new();
4677
4678        for (provider, value) in [
4679            ("tavily", state.tavily_api_key.trim()),
4680            ("exa", state.exa_api_key.trim()),
4681        ] {
4682            if value.is_empty() {
4683                continue;
4684            }
4685
4686            match auth_store.store(
4687                provider,
4688                imp_llm::auth::StoredCredential::ApiKey {
4689                    key: value.to_string(),
4690                },
4691            ) {
4692                Ok(()) => auth_notes.push(format!("saved {provider} key")),
4693                Err(e) => {
4694                    self.messages.push(DisplayMessage {
4695                        role: MessageRole::Error,
4696                        content: format!("Failed to save {provider} API key: {e}"),
4697                        thinking: None,
4698                        tool_calls: Vec::new(),
4699                        assistant_blocks: Vec::new(),
4700                        is_streaming: false,
4701                        timestamp: imp_llm::now(),
4702                    });
4703                }
4704            }
4705        }
4706
4707        // Persist to user config.toml
4708        let config_path = imp_core::storage::global_config_path();
4709        match self.config.save(&config_path) {
4710            Ok(()) => {
4711                if let UiMode::Settings(ref mut s) = self.mode {
4712                    s.dirty = false;
4713                    s.tavily_api_key.clear();
4714                    s.exa_api_key.clear();
4715                    s.tavily_configured = provider_logged_in(&auth_store, "tavily");
4716                    s.exa_configured = provider_logged_in(&auth_store, "exa");
4717                }
4718                let mut message = format!("Settings saved to {}", config_path.display());
4719                if !auth_notes.is_empty() {
4720                    message.push_str(&format!(" ({})", auth_notes.join(", ")));
4721                }
4722                self.messages.push(DisplayMessage {
4723                    role: MessageRole::System,
4724                    content: message,
4725                    thinking: None,
4726                    tool_calls: Vec::new(),
4727                    assistant_blocks: Vec::new(),
4728                    is_streaming: false,
4729                    timestamp: imp_llm::now(),
4730                });
4731            }
4732            Err(e) => {
4733                self.messages.push(DisplayMessage {
4734                    role: MessageRole::Error,
4735                    content: format!("Failed to save settings: {e}"),
4736                    thinking: None,
4737                    tool_calls: Vec::new(),
4738                    assistant_blocks: Vec::new(),
4739                    is_streaming: false,
4740                    timestamp: imp_llm::now(),
4741                });
4742            }
4743        }
4744    }
4745
4746    /// Return models filtered by `config.enabled_models` (if set) and by
4747    /// available credentials. Models whose provider has no auth configured
4748    /// are hidden unless explicitly listed in `enabled_models`.
4749    fn filtered_models(&self) -> Vec<ModelMeta> {
4750        let auth_path = imp_core::storage::global_auth_path();
4751        let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
4752        filtered_model_options(&self.model_registry, &self.config, &auth_store)
4753    }
4754
4755    fn open_model_selector(&mut self) {
4756        let models = self.filtered_models();
4757        let (models, current_model) =
4758            include_current_model_option(models, &self.model_registry, &self.model_name);
4759        self.mode = UiMode::ModelSelector(ModelSelectorState::new(models, current_model));
4760    }
4761
4762    fn open_file_finder(&mut self) {
4763        let files = collect_project_files(&self.cwd, 5000);
4764        self.mode = UiMode::FileFinder(FileFinderState::new(files));
4765    }
4766
4767    fn open_tree_view(&mut self) {
4768        let tree = self.session.get_tree();
4769        let flat = flatten_tree(&tree, 0);
4770        if flat.is_empty() {
4771            self.push_system_msg("No session history yet.");
4772            return;
4773        }
4774        let current_id = self.session.leaf_id().map(String::from);
4775        self.mode = UiMode::TreeView(TreeViewState::new(flat, current_id));
4776    }
4777
4778    fn cycle_model(&mut self, forward: bool) {
4779        let models = self.filtered_models();
4780        if models.is_empty() {
4781            return;
4782        }
4783        let current_idx = models.iter().position(|m| m.id == self.model_name);
4784        let next_idx = match current_idx {
4785            Some(idx) => {
4786                if forward {
4787                    (idx + 1) % models.len()
4788                } else {
4789                    (idx + models.len() - 1) % models.len()
4790                }
4791            }
4792            None => 0,
4793        };
4794        self.model_name = models[next_idx].id.clone();
4795        self.context_window = models[next_idx].context_window;
4796        self.invalidate_chat_render_cache();
4797        self.push_system_msg(&format!("Model: {}", self.model_name));
4798    }
4799
4800    fn cycle_thinking_level(&mut self) {
4801        self.invalidate_chat_render_cache();
4802        self.thinking_level = match self.thinking_level {
4803            ThinkingLevel::Off => ThinkingLevel::Low,
4804            ThinkingLevel::Minimal => ThinkingLevel::Low,
4805            ThinkingLevel::Low => ThinkingLevel::Medium,
4806            ThinkingLevel::Medium => ThinkingLevel::High,
4807            ThinkingLevel::High => ThinkingLevel::XHigh,
4808            ThinkingLevel::XHigh => ThinkingLevel::Off,
4809        };
4810    }
4811
4812    // ── Helpers ──────────────────────────────────────────────────
4813
4814    fn push_system_msg(&mut self, content: &str) {
4815        self.push_message(MessageRole::System, content);
4816    }
4817
4818    fn push_warning_msg(&mut self, content: &str) {
4819        self.push_message(MessageRole::Warning, content);
4820    }
4821
4822    fn push_error_msg(&mut self, content: &str) {
4823        self.push_message(MessageRole::Error, content);
4824    }
4825
4826    fn push_message(&mut self, role: MessageRole, content: &str) {
4827        self.messages.push(DisplayMessage {
4828            role,
4829            content: content.to_string(),
4830            thinking: None,
4831            tool_calls: Vec::new(),
4832            assistant_blocks: Vec::new(),
4833            is_streaming: false,
4834            timestamp: imp_llm::now(),
4835        });
4836        self.invalidate_chat_render_cache();
4837    }
4838
4839    fn latest_streaming_message_mut(&mut self) -> Option<&mut DisplayMessage> {
4840        self.messages.iter_mut().rev().find(|msg| msg.is_streaming)
4841    }
4842
4843    fn find_tool_call_mut(&mut self, tool_call_id: &str) -> Option<&mut DisplayToolCall> {
4844        for msg in self.messages.iter_mut().rev() {
4845            if let Some(tc) = msg.tool_calls.iter_mut().find(|tc| tc.id == tool_call_id) {
4846                return Some(tc);
4847            }
4848        }
4849        None
4850    }
4851
4852    fn run_manual_compaction(&mut self) {
4853        if self.is_streaming {
4854            self.push_error_msg("Cannot compact while the agent is actively streaming.");
4855            return;
4856        }
4857
4858        let active_messages = self.session.get_active_messages();
4859        let prepared =
4860            prepare_messages_for_compaction(&active_messages, DEFAULT_KEEP_RECENT_GROUPS);
4861        if !prepared.should_compact() {
4862            self.push_system_msg("Not enough history to compact yet.");
4863            return;
4864        }
4865
4866        let auth_path = imp_core::storage::global_auth_path();
4867        let mut auth_store =
4868            AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path.clone()));
4869
4870        let mut meta = match self.model_registry.resolve_meta(&self.model_name, None) {
4871            Some(meta) => meta,
4872            None => {
4873                self.push_error_msg(&format!("Unknown model: {}", self.model_name));
4874                return;
4875            }
4876        };
4877
4878        let mut provider_name = meta.provider.clone();
4879        if should_use_chatgpt_provider(&auth_store, &self.model_registry, &meta) {
4880            provider_name = "openai-codex".to_string();
4881            if let Some(resolved) = self
4882                .model_registry
4883                .resolve_meta(&self.model_name, Some(&provider_name))
4884            {
4885                meta = resolved;
4886            }
4887        }
4888
4889        let provider = match create_provider(&provider_name) {
4890            Some(provider) => provider,
4891            None => {
4892                self.push_error_msg(&format!("Unknown provider: {provider_name}"));
4893                return;
4894            }
4895        };
4896
4897        let api_key = match tokio::task::block_in_place(|| {
4898            tokio::runtime::Handle::current()
4899                .block_on(resolve_provider_api_key(&mut auth_store, &provider_name))
4900        }) {
4901            Ok(key) => key,
4902            Err(e) => {
4903                self.push_error_msg(&format!("Failed to resolve auth for compaction: {e}"));
4904                return;
4905            }
4906        };
4907
4908        let model = Model {
4909            meta,
4910            provider: Arc::from(provider),
4911        };
4912        let model_id = model.meta.id.clone();
4913        let model_meta = model.meta.clone();
4914        let model_provider = Arc::clone(&model.provider);
4915        let requested_max_tokens = self.config.max_tokens;
4916
4917        let mut config = self.config.clone();
4918        config.thinking = Some(self.thinking_level);
4919
4920        let lua_cwd = self.cwd.clone();
4921        let user_config_dir = imp_core::config::Config::user_config_dir();
4922        let (agent, _handle) = match AgentBuilder::new(config, self.cwd.clone(), model, api_key)
4923            .lua_tool_loader(move |policy, tools| {
4924                imp_lua::init_lua_extensions(&user_config_dir, Some(&lua_cwd), tools, policy);
4925            })
4926            .build()
4927        {
4928            Ok(built) => built,
4929            Err(e) => {
4930                self.push_error_msg(&format!("Failed to build compaction agent: {e}"));
4931                return;
4932            }
4933        };
4934
4935        let system_prompt = agent.system_prompt.clone();
4936
4937        let strategy = select_compaction_strategy(&CompactionCapabilities {
4938            provider_id: &provider_name,
4939            model_id: &model_id,
4940            allow_provider_native: false,
4941        });
4942        if matches!(strategy, CompactionStrategy::ProviderNative) {
4943            self.push_system_msg(
4944                "Provider-native compaction is not enabled yet; falling back to local compaction.",
4945            );
4946        }
4947
4948        let result = execute_compaction_with_retry(
4949            &mut self.session,
4950            DEFAULT_KEEP_RECENT_GROUPS,
4951            2,
4952            |prompt| {
4953                use futures::StreamExt;
4954                use imp_llm::provider::{CacheOptions, Context as LlmContext, RequestOptions};
4955                use imp_llm::StreamEvent;
4956
4957                let model_meta = model_meta.clone();
4958                let model_provider = Arc::clone(&model_provider);
4959                let api_key = agent.api_key.clone();
4960                let system_prompt = system_prompt.clone();
4961                let prompt = prompt.to_string();
4962                let thinking_level = self.thinking_level;
4963                let retry_policy = agent.retry_policy.clone();
4964
4965                tokio::task::block_in_place(|| {
4966                    let runtime = tokio::runtime::Handle::current();
4967                    runtime.block_on(async move {
4968                        let mut summary = String::new();
4969                        let mut message_end_text: Option<String> = None;
4970
4971                        let model = Model {
4972                            meta: model_meta,
4973                            provider: model_provider,
4974                        };
4975                        let context = LlmContext {
4976                            messages: vec![Message::user(prompt)],
4977                        };
4978                        let options = RequestOptions {
4979                            thinking_level,
4980                            max_tokens: requested_max_tokens.or(Some(2048)),
4981                            temperature: Some(0.2),
4982                            system_prompt,
4983                            tools: Vec::new(),
4984                            cache_options: CacheOptions::default(),
4985                            effort: None,
4986                        };
4987
4988                        let mut stream = imp_core::retry::stream_with_retry(
4989                            move || {
4990                                model.provider.stream(
4991                                    &model,
4992                                    context.clone(),
4993                                    options.clone(),
4994                                    &api_key,
4995                                )
4996                            },
4997                            retry_policy,
4998                        );
4999
5000                        while let Some(item) = stream.next().await {
5001                            match item {
5002                                Ok(StreamEvent::TextDelta { text }) => summary.push_str(&text),
5003                                Ok(StreamEvent::MessageEnd { message }) => {
5004                                    let body = message
5005                                        .content
5006                                        .iter()
5007                                        .filter_map(|block| match block {
5008                                            imp_llm::ContentBlock::Text { text } => {
5009                                                Some(text.as_str())
5010                                            }
5011                                            _ => None,
5012                                        })
5013                                        .collect::<Vec<_>>()
5014                                        .join("");
5015                                    if !body.is_empty() {
5016                                        message_end_text = Some(body);
5017                                    }
5018                                }
5019                                Ok(_) => {}
5020                                Err(_) => return None,
5021                            }
5022                        }
5023
5024                        let final_text = if !summary.trim().is_empty() {
5025                            summary
5026                        } else {
5027                            message_end_text.unwrap_or_default()
5028                        };
5029                        (!final_text.trim().is_empty()).then_some(final_text)
5030                    })
5031                })
5032            },
5033        );
5034
5035        match result {
5036            Ok(Some(compaction)) => {
5037                self.load_session_messages();
5038                self.messages.push(DisplayMessage {
5039                    role: MessageRole::Compaction,
5040                    content: format!(
5041                        "Context compacted. Saved ~{} tokens. Preserved recent working context.",
5042                        compaction
5043                            .tokens_before
5044                            .saturating_sub(compaction.tokens_after)
5045                    ),
5046                    thinking: None,
5047                    tool_calls: Vec::new(),
5048                    assistant_blocks: Vec::new(),
5049                    is_streaming: false,
5050                    timestamp: imp_llm::now(),
5051                });
5052                self.push_system_msg(&format!(
5053                    "Compaction summary stored. Active context now uses the compacted branch view."
5054                ));
5055            }
5056            Ok(None) => {
5057                self.push_system_msg("Not enough history to compact yet.");
5058            }
5059            Err(e) => {
5060                self.push_error_msg(&format!("Compaction failed: {e}"));
5061            }
5062        }
5063    }
5064
5065    fn export_conversation(&self, path: &std::path::Path) -> std::io::Result<()> {
5066        use std::io::Write;
5067        let mut f = std::fs::File::create(path)?;
5068        for msg in &self.messages {
5069            let role = match msg.role {
5070                MessageRole::User => "**You:**",
5071                MessageRole::Assistant => "**Assistant:**",
5072                MessageRole::System | MessageRole::Compaction => "*System:*",
5073                MessageRole::Warning => "*Warning:*",
5074                MessageRole::Error => "**Error:**",
5075            };
5076            writeln!(f, "{role}\n{}\n", msg.content)?;
5077            for tc in &msg.tool_calls {
5078                writeln!(f, "> `{}`: {}", tc.name, tc.args_summary)?;
5079                if let Some(ref output) = tc.output {
5080                    let preview = truncate_chars_with_suffix(output, 200, "");
5081                    writeln!(f, "> {preview}\n")?;
5082                }
5083            }
5084        }
5085        Ok(())
5086    }
5087
5088    // ── Agent event handling ────────────────────────────────────
5089
5090    pub fn handle_agent_event(&mut self, event: AgentEvent) {
5091        match event {
5092            AgentEvent::AgentStart { model, .. } => {
5093                self.model_name = model;
5094                self.is_streaming = true;
5095                self.tool_focus = None;
5096                self.tool_focus_pinned = false;
5097                self.sidebar_auto_follow = true;
5098                self.invalidate_chat_render_cache();
5099                self.turn_tracker.reset();
5100            }
5101            AgentEvent::AgentEnd { cost, .. } => {
5102                self.completed_turns_in_run = self.completed_turns_in_run.max(1);
5103                self.accumulated_cost.total += cost.total;
5104                self.accumulated_cost.input += cost.input;
5105                self.accumulated_cost.output += cost.output;
5106                self.is_streaming = false;
5107
5108                // Mark last streaming message as done
5109                if let Some(last) = self.latest_streaming_message_mut() {
5110                    last.is_streaming = false;
5111                }
5112                self.invalidate_chat_render_cache();
5113
5114                // Process follow-up messages
5115                let follow_ups: Vec<_> = self
5116                    .message_queue
5117                    .drain(..)
5118                    .filter_map(|m| match m {
5119                        QueuedMessage::FollowUp(text) => Some(text),
5120                        _ => None,
5121                    })
5122                    .collect();
5123                for text in follow_ups {
5124                    self.editor.set_content(&text);
5125                    self.send_message();
5126                }
5127            }
5128            AgentEvent::MessageDelta { delta } => {
5129                // Keep the current default compact: the main transcript shows
5130                // where the tool ran, and the sidebar inspector owns details.
5131                let tools_expanded = self.tools_expanded
5132                    && self.config.ui.effective_chat_tool_display()
5133                        == imp_core::config::ChatToolDisplay::Interleaved;
5134                if let Some(last) = self.latest_streaming_message_mut() {
5135                    match delta {
5136                        StreamEvent::TextDelta { text } => {
5137                            last.push_assistant_text_delta(&text);
5138                        }
5139                        StreamEvent::ThinkingDelta { text } => match &mut last.thinking {
5140                            Some(t) => t.push_str(&text),
5141                            None => last.thinking = Some(text),
5142                        },
5143                        StreamEvent::ToolCall {
5144                            id,
5145                            name,
5146                            arguments,
5147                        } => {
5148                            last.push_assistant_tool_call(DisplayToolCall {
5149                                id,
5150                                args_summary: DisplayToolCall::make_args_summary(&name, &arguments),
5151                                name,
5152                                output: None,
5153                                details: arguments,
5154                                is_error: false,
5155                                expanded: tools_expanded,
5156                                streaming_lines: Vec::new(),
5157                                streaming_output: String::new(),
5158                            });
5159                        }
5160                        _ => {}
5161                    }
5162                }
5163                self.invalidate_chat_render_cache();
5164                // Auto-scroll to bottom
5165                if self.auto_scroll {
5166                    self.scroll_offset = 0;
5167                }
5168            }
5169            AgentEvent::ToolExecutionStart {
5170                tool_call_id,
5171                tool_name,
5172                args,
5173            } => {
5174                self.turn_tracker
5175                    .record_tool_start(&tool_call_id, &tool_name, &args);
5176                // Find the matching tool call and update it
5177                if let Some(tc) = self.find_tool_call_mut(&tool_call_id) {
5178                    tc.args_summary = DisplayToolCall::make_args_summary(&tool_name, &args);
5179                    tc.details = args;
5180                }
5181                self.invalidate_chat_render_cache();
5182                // Sidebar: follow the new tool only until the user pins an older selection.
5183                if let Some(idx) = self.find_tool_call_index(&tool_call_id) {
5184                    if !self.tool_focus_pinned {
5185                        self.focus_tool_with_pin(idx, false);
5186                    }
5187                    if self.sidebar_auto_follow
5188                        && matches!(
5189                            self.config.ui.sidebar_style,
5190                            imp_core::config::SidebarStyle::Stream
5191                                | imp_core::config::SidebarStyle::Inspector
5192                        )
5193                    {
5194                        self.sidebar.detail_scroll = usize::MAX;
5195                    }
5196                }
5197                // Auto-open on first tool if terminal is wide enough, or whenever
5198                // chat tool calls are hidden and the sidebar is their only surface.
5199                if !self.sidebar.first_tool_seen {
5200                    self.sidebar.first_tool_seen = true;
5201                    let (cols, _) = crossterm::terminal::size().unwrap_or((80, 24));
5202                    if self.config.ui.effective_chat_tool_display()
5203                        == imp_core::config::ChatToolDisplay::Hidden
5204                        || (self.config.ui.auto_open_sidebar
5205                            && cols >= self.config.ui.sidebar_auto_open_width)
5206                    {
5207                        self.sidebar.open = true;
5208                    }
5209                }
5210            }
5211            AgentEvent::ToolOutputDelta { tool_call_id, text } => {
5212                let streaming_lines_limit = self.config.ui.streaming_lines;
5213                // Feed streaming output into the tool call's rolling buffer
5214                if let Some(tc) = self.find_tool_call_mut(&tool_call_id) {
5215                    // Append text to the full live transcript.
5216                    if !tc.streaming_output.is_empty() {
5217                        tc.streaming_output.push('\n');
5218                    }
5219                    tc.streaming_output.push_str(&text);
5220                    // Append text and keep configured rolling tail for chat.
5221                    for line in text.lines() {
5222                        tc.streaming_lines.push(line.to_string());
5223                    }
5224                    if tc.streaming_lines.len() > streaming_lines_limit {
5225                        let excess = tc.streaming_lines.len() - streaming_lines_limit;
5226                        tc.streaming_lines.drain(..excess);
5227                    }
5228                }
5229                self.invalidate_chat_render_cache();
5230                if self.auto_scroll {
5231                    self.scroll_offset = 0;
5232                }
5233            }
5234            AgentEvent::ToolExecutionEnd {
5235                tool_call_id,
5236                result,
5237            } => {
5238                let is_error = result.is_error;
5239                self.turn_tracker.record_tool_end(&tool_call_id, is_error);
5240                // Build display text from result content
5241                let output_text = result
5242                    .content
5243                    .iter()
5244                    .filter_map(|b| match b {
5245                        imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
5246                        _ => None,
5247                    })
5248                    .collect::<Vec<_>>()
5249                    .join("");
5250                let inline_output_enabled = self.config.ui.effective_chat_tool_display()
5251                    == imp_core::config::ChatToolDisplay::Interleaved;
5252                // Attach result to the matching display tool call
5253                if let Some(tc) = self.find_tool_call_mut(&tool_call_id) {
5254                    tc.output = Some(output_text.clone());
5255                    if tc.streaming_output.is_empty() {
5256                        tc.streaming_output = output_text.clone();
5257                    }
5258                    tc.details = result.details.clone();
5259                    tc.is_error = is_error;
5260                    // Auto-expand failed tool calls so the error is immediately visible
5261                    // when inline tool output is enabled. In the default inspector flow,
5262                    // the selected sidebar owns full error details instead.
5263                    if is_error {
5264                        tc.expanded = inline_output_enabled;
5265                    }
5266                }
5267
5268                self.invalidate_chat_render_cache();
5269
5270                // Persist tool result to session so resume has full conversation
5271                let _ = self.session.append_tool_result_message(result);
5272            }
5273            AgentEvent::Warning { message } => {
5274                self.push_warning_msg(&message);
5275            }
5276            AgentEvent::Timing { timing } => {
5277                self.status_items.insert(
5278                    "timing".to_string(),
5279                    format!(
5280                        "{} {}ms",
5281                        timing.stage.as_str(),
5282                        timing.since_llm_request_start_ms
5283                    ),
5284                );
5285            }
5286            AgentEvent::TurnEnd {
5287                index,
5288                message,
5289                mana_review: _,
5290            } => {
5291                self.completed_turns_in_run += 1;
5292                // Update context tracking from this turn's usage
5293                if let Some(ref usage) = message.usage {
5294                    self.current_context_tokens = usage.input_tokens + usage.cache_read_tokens;
5295                    self.accumulated_usage.add(usage);
5296                }
5297
5298                // Persist assistant message to session, plus canonical usage when possible.
5299                if let Some(model_meta) = self.current_model_meta_for_persistence() {
5300                    let _ = self.session.append_assistant_turn_with_model_meta(
5301                        &model_meta,
5302                        index,
5303                        message,
5304                    );
5305                } else {
5306                    let msg_id = uuid::Uuid::new_v4().to_string();
5307                    let _ = self.session.append(SessionEntry::Message {
5308                        id: msg_id,
5309                        parent_id: None,
5310                        message: imp_llm::Message::Assistant(message),
5311                    });
5312                }
5313            }
5314            AgentEvent::Error { error } => {
5315                self.completed_turns_in_run = 0;
5316                // Stop streaming — errors can be terminal (no AgentEnd follows)
5317                self.is_streaming = false;
5318                if let Some(last) = self.latest_streaming_message_mut() {
5319                    last.is_streaming = false;
5320                }
5321                self.invalidate_chat_render_cache();
5322
5323                // Parse the error for a cleaner display
5324                let display_error = format_error_for_display(&error);
5325
5326                self.messages.push(DisplayMessage {
5327                    role: MessageRole::Error,
5328                    content: display_error,
5329                    thinking: None,
5330                    tool_calls: Vec::new(),
5331                    assistant_blocks: Vec::new(),
5332                    is_streaming: false,
5333                    timestamp: imp_llm::now(),
5334                });
5335                self.invalidate_chat_render_cache();
5336            }
5337            _ => {}
5338        }
5339    }
5340}
5341
5342// ── Layout helpers ──────────────────────────────────────────────
5343
5344/// Create a centered rect using percentage of the available area.
5345fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
5346    let popup_layout = Layout::default()
5347        .direction(Direction::Vertical)
5348        .constraints([
5349            Constraint::Percentage((100 - percent_y) / 2),
5350            Constraint::Percentage(percent_y),
5351            Constraint::Percentage((100 - percent_y) / 2),
5352        ])
5353        .split(area);
5354
5355    Layout::default()
5356        .direction(Direction::Horizontal)
5357        .constraints([
5358            Constraint::Percentage((100 - percent_x) / 2),
5359            Constraint::Percentage(percent_x),
5360            Constraint::Percentage((100 - percent_x) / 2),
5361        ])
5362        .split(popup_layout[1])[1]
5363}
5364
5365/// Check if a point is inside an optional rect.
5366fn point_in_rect(col: u16, row: u16, rect: Option<Rect>) -> bool {
5367    match rect {
5368        Some(r) => col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height,
5369        None => false,
5370    }
5371}
5372
5373/// Create an area above the editor for a dropdown.
5374fn command_dropdown_area(editor_area: Rect, max_height: u16) -> Rect {
5375    let height = max_height.min(editor_area.y);
5376    Rect {
5377        x: editor_area.x,
5378        y: editor_area.y.saturating_sub(height),
5379        width: editor_area.width.min(60),
5380        height,
5381    }
5382}
5383
5384#[cfg(test)]
5385mod session_lifecycle {
5386    use super::*;
5387    use imp_core::config::Config;
5388    use imp_core::session::{SessionEntry, SessionManager};
5389    use imp_llm::auth::{AuthStore, OAuthCredential, StoredCredential};
5390    use imp_llm::model::ModelRegistry;
5391    use imp_llm::ThinkingLevel;
5392    use imp_llm::{AssistantMessage, ContentBlock, StopReason};
5393    use ratatui::buffer::Buffer;
5394    use ratatui::layout::Rect;
5395    use ratatui::widgets::Widget;
5396    use tempfile::TempDir;
5397
5398    /// Helper: build an App with defaults and an in-memory session.
5399    fn make_app() -> App {
5400        let config = Config::default();
5401        let session = SessionManager::in_memory();
5402        let registry = ModelRegistry::with_builtins();
5403        App::new(config, session, registry, PathBuf::from("/tmp/test"))
5404    }
5405
5406    /// Helper: build an App with defaults and a provided session.
5407    fn make_app_with_session(session: SessionManager, cwd: PathBuf) -> App {
5408        let config = Config::default();
5409        let registry = ModelRegistry::with_builtins();
5410        App::new(config, session, registry, cwd)
5411    }
5412
5413    /// Helper: build an App backed by a persistent session in `dir`.
5414    fn make_persistent_app(tmp: &TempDir) -> App {
5415        let cwd = tmp.path().join("project");
5416        let session_dir = tmp.path().join("sessions");
5417        let session = SessionManager::new(&cwd, &session_dir).unwrap();
5418        let config = Config {
5419            model: Some("sonnet".into()),
5420            ..Config::default()
5421        };
5422        let registry = ModelRegistry::with_builtins();
5423        App::new(config, session, registry, cwd)
5424    }
5425
5426    fn render_status_to_string(info: &StatusInfo, width: u16) -> String {
5427        let theme = Theme::default();
5428        let area = Rect::new(0, 0, width, 1);
5429        let mut buf = Buffer::empty(area);
5430        crate::views::status::StatusBar::new(info, &theme).render(area, &mut buf);
5431
5432        (0..area.width)
5433            .map(|x| {
5434                buf.cell((x, 0))
5435                    .unwrap()
5436                    .symbol()
5437                    .chars()
5438                    .next()
5439                    .unwrap_or(' ')
5440            })
5441            .collect()
5442    }
5443
5444    #[test]
5445    fn filtered_model_options_includes_chatgpt_oauth_only_models() {
5446        let registry = ModelRegistry::with_builtins();
5447        let tmp = tempfile::tempdir().unwrap();
5448        let auth_path = tmp.path().join("auth.json");
5449        let mut auth_store = AuthStore::new(auth_path);
5450        auth_store
5451            .store(
5452                "openai",
5453                StoredCredential::OAuth(OAuthCredential {
5454                    access_token: "oauth-token".into(),
5455                    refresh_token: "refresh-token".into(),
5456                    expires_at: imp_llm::now() + 3600,
5457                }),
5458            )
5459            .unwrap();
5460
5461        let models = filtered_model_options(&registry, &Config::default(), &auth_store);
5462        let model = models
5463            .iter()
5464            .find(|model| model.id == "gpt-5.5")
5465            .expect("gpt-5.5 should be visible for ChatGPT OAuth users");
5466        assert_eq!(model.provider, "openai");
5467
5468        let openai_model_index = models
5469            .iter()
5470            .position(|model| model.id == "gpt-5.3-codex-spark")
5471            .expect("built-in OpenAI model should be visible");
5472        let oauth_model_index = models
5473            .iter()
5474            .position(|model| model.id == "gpt-5.5")
5475            .expect("ChatGPT OAuth-only model should be visible");
5476        assert!(openai_model_index < oauth_model_index);
5477    }
5478
5479    #[test]
5480    fn filtered_model_options_hides_chatgpt_oauth_only_models_when_openai_api_key_exists() {
5481        let registry = ModelRegistry::with_builtins();
5482        let tmp = tempfile::tempdir().unwrap();
5483        let auth_path = tmp.path().join("auth.json");
5484        let mut auth_store = AuthStore::new(auth_path);
5485        auth_store
5486            .store(
5487                "openai",
5488                StoredCredential::ApiKey {
5489                    key: "sk-openai".into(),
5490                },
5491            )
5492            .unwrap();
5493        auth_store
5494            .store(
5495                "openai-codex",
5496                StoredCredential::OAuth(OAuthCredential {
5497                    access_token: "oauth-token".into(),
5498                    refresh_token: "refresh-token".into(),
5499                    expires_at: imp_llm::now() + 3600,
5500                }),
5501            )
5502            .unwrap();
5503
5504        let models = filtered_model_options(&registry, &Config::default(), &auth_store);
5505        assert!(!models.iter().any(|model| model.id == "gpt-5.5"));
5506    }
5507
5508    #[test]
5509    fn model_picker_includes_current_alias_even_without_auth() {
5510        let registry = ModelRegistry::with_builtins();
5511        let tmp = tempfile::tempdir().unwrap();
5512        let auth_store = AuthStore::new(tmp.path().join("auth.json"));
5513        let models = filtered_model_options(&registry, &Config::default(), &auth_store);
5514        assert!(models.is_empty());
5515
5516        let (models, current_model) = include_current_model_option(models, &registry, "kimi");
5517
5518        assert_eq!(current_model, "kimi-k2.6");
5519        assert!(models.iter().any(|model| model.id == "kimi-k2.6"));
5520    }
5521
5522    #[test]
5523    fn terminal_title_uses_manual_session_name_when_present() {
5524        let mut app = make_app();
5525        app.session.set_name("my chat");
5526        assert_eq!(app.terminal_title(), "imp — my chat");
5527    }
5528
5529    #[test]
5530    fn terminal_title_falls_back_to_summarized_first_prompt() {
5531        let mut app = make_app();
5532        app.session
5533            .append(SessionEntry::Message {
5534                id: "m1".into(),
5535                parent_id: None,
5536                message: Message::user(
5537                    "can we adjust the information that is displayed in the top bar",
5538                ),
5539            })
5540            .unwrap();
5541        assert_eq!(app.terminal_title(), "imp — adjust top bar");
5542    }
5543
5544    #[test]
5545    fn terminal_title_replaces_imp_with_spinner_while_streaming() {
5546        let mut app = make_app();
5547        app.session.set_name("my chat");
5548        app.is_streaming = true;
5549        app.tick = 0;
5550        assert_eq!(app.terminal_title(), "⠋ — my chat");
5551    }
5552
5553    #[test]
5554    fn terminal_title_defaults_to_chat_when_empty() {
5555        let app = make_app();
5556        assert_eq!(app.terminal_title(), "imp — chat");
5557    }
5558
5559    // ── 1. App::new creates with config + session ───────────────
5560
5561    #[test]
5562    fn tui_integration_app_new_defaults() {
5563        let app = make_app();
5564
5565        assert!(app.running);
5566        assert!(app.messages.is_empty());
5567        assert_eq!(app.model_name, "sonnet");
5568        assert_eq!(app.thinking_level, ThinkingLevel::Medium);
5569        assert_eq!(app.context_window, 1_000_000);
5570        assert!(!app.is_streaming);
5571        assert!(app.agent_handle.is_none());
5572        assert!(matches!(app.mode, UiMode::Normal));
5573    }
5574
5575    #[test]
5576    fn tui_integration_app_new_with_custom_config() {
5577        let config = Config {
5578            model: Some("haiku".into()),
5579            thinking: Some(ThinkingLevel::High),
5580            ..Config::default()
5581        };
5582        let session = SessionManager::in_memory();
5583        let registry = ModelRegistry::with_builtins();
5584        let app = App::new(config, session, registry, PathBuf::from("/tmp"));
5585
5586        assert_eq!(app.model_name, "haiku");
5587        assert_eq!(app.thinking_level, ThinkingLevel::High);
5588    }
5589
5590    #[test]
5591    fn tui_integration_app_new_persistent_session() {
5592        let tmp = TempDir::new().unwrap();
5593        let app = make_persistent_app(&tmp);
5594
5595        // Session is backed by a file on disk
5596        assert!(app.session.path().is_some());
5597        assert!(app.session.path().unwrap().exists());
5598    }
5599
5600    // ── 2. send_message persists to session ─────────────────────
5601
5602    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5603    async fn tui_integration_send_message_persists() {
5604        let tmp = TempDir::new().unwrap();
5605        let mut app = make_persistent_app(&tmp);
5606
5607        // Type a message and send
5608        app.editor.set_content("hello world");
5609        app.send_message();
5610
5611        // User message persisted to session (even though agent spawn fails)
5612        let messages = app.session.get_messages();
5613        assert_eq!(messages.len(), 1);
5614        assert!(messages[0].is_user());
5615
5616        // Display should have user msg + error (agent spawn fails without auth)
5617        assert!(app.messages.len() >= 2);
5618        assert_eq!(app.messages[0].role, MessageRole::User);
5619        assert_eq!(app.messages[0].content, "hello world");
5620    }
5621
5622    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5623    async fn tui_integration_send_message_large_paste_displays_full_text() {
5624        let tmp = TempDir::new().unwrap();
5625        let mut app = make_persistent_app(&tmp);
5626        let pasted = (1..=25)
5627            .map(|i| format!("fn example_{i}() {{}}"))
5628            .collect::<Vec<_>>()
5629            .join("\n");
5630
5631        app.editor.set_content(&pasted);
5632        app.send_message();
5633
5634        assert!(app.messages.len() >= 2);
5635        assert_eq!(app.messages[0].role, MessageRole::User);
5636        assert_eq!(app.messages[0].content, pasted);
5637
5638        let persisted = app.session.get_messages();
5639        assert_eq!(persisted.len(), 1);
5640        let stored_text = match &persisted[0] {
5641            imp_llm::Message::User(user) => match user.content.as_slice() {
5642                [imp_llm::ContentBlock::Text { text }] => text.clone(),
5643                other => panic!("unexpected user content: {other:?}"),
5644            },
5645            other => panic!("expected user message, got {other:?}"),
5646        };
5647        assert_eq!(stored_text, pasted);
5648    }
5649
5650    #[test]
5651    fn tui_integration_send_message_empty_ignored() {
5652        let mut app = make_app();
5653
5654        // Empty editor — send_message should be a no-op
5655        app.send_message();
5656        assert!(app.messages.is_empty());
5657        assert_eq!(app.session.get_messages().len(), 0);
5658
5659        // Whitespace-only too
5660        app.editor.set_content("   ");
5661        app.send_message();
5662        assert!(app.messages.is_empty());
5663    }
5664
5665    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5666    async fn tui_integration_send_message_persists_to_disk() {
5667        let tmp = TempDir::new().unwrap();
5668        let mut app = make_persistent_app(&tmp);
5669        let session_path = app.session.path().unwrap().to_path_buf();
5670
5671        app.editor.set_content("persist me");
5672        app.send_message();
5673
5674        // Reopen the file and verify the message is there
5675        let reopened = SessionManager::open(&session_path).unwrap();
5676        let msgs = reopened.get_messages();
5677        assert_eq!(msgs.len(), 1);
5678        assert!(msgs[0].is_user());
5679    }
5680
5681    // ── 3. Slash commands ───────────────────────────────────────
5682
5683    #[test]
5684    fn tui_integration_slash_new_clears_session() {
5685        let mut app = make_app();
5686
5687        // Add some messages first
5688        app.messages.push(DisplayMessage {
5689            role: MessageRole::User,
5690            content: "old message".into(),
5691            thinking: None,
5692            tool_calls: Vec::new(),
5693            assistant_blocks: Vec::new(),
5694            is_streaming: false,
5695            timestamp: 0,
5696        });
5697        app.accumulated_usage = Usage {
5698            input_tokens: 12_345,
5699            output_tokens: 678,
5700            cache_read_tokens: 90,
5701            cache_write_tokens: 0,
5702        };
5703        app.accumulated_cost = Cost {
5704            input: 0.5,
5705            output: 0.25,
5706            cache_read: 0.0,
5707            cache_write: 0.0,
5708            total: 0.75,
5709        };
5710        app.current_context_tokens = 12_435;
5711        assert_eq!(app.messages.len(), 1);
5712
5713        // Execute /new
5714        app.execute_command("new");
5715
5716        assert!(app.messages.is_empty());
5717        assert_eq!(app.accumulated_usage, Usage::default());
5718        assert_eq!(app.accumulated_cost, Cost::default());
5719        assert_eq!(app.current_context_tokens, 0);
5720        // Session replaced with in-memory
5721        assert!(app.session.path().is_none());
5722    }
5723
5724    #[test]
5725    fn tui_integration_slash_new_resets_rendered_context_percent() {
5726        let mut app = make_app();
5727        app.context_window = 200_000;
5728        app.accumulated_usage = Usage {
5729            input_tokens: 12_345,
5730            output_tokens: 678,
5731            cache_read_tokens: 0,
5732            cache_write_tokens: 0,
5733        };
5734        app.current_context_tokens = 50_000;
5735
5736        let before = app.build_status_info();
5737        let before_render = render_status_to_string(&before, 120);
5738        assert!(before.context_percent > 0.0);
5739        assert!(before_render.contains("25%"));
5740
5741        app.execute_command("new");
5742
5743        let after = app.build_status_info();
5744        let after_render = render_status_to_string(&after, 120);
5745        assert_eq!(after.context_percent, 0.0);
5746        assert!(after_render.contains("0%"));
5747    }
5748
5749    #[test]
5750    fn tui_integration_slash_compact_noops_with_short_history() {
5751        let mut app = make_app();
5752
5753        app.execute_command("compact");
5754
5755        assert_eq!(app.messages.len(), 1);
5756        assert_eq!(app.messages[0].role, MessageRole::System);
5757        assert_eq!(
5758            app.messages[0].content,
5759            "Not enough history to compact yet."
5760        );
5761    }
5762
5763    #[test]
5764    fn load_session_messages_uses_compacted_active_history() {
5765        let mut app = make_app();
5766        app.session
5767            .append(SessionEntry::Message {
5768                id: "u1".into(),
5769                parent_id: None,
5770                message: Message::user("older request"),
5771            })
5772            .unwrap();
5773        app.session
5774            .append(SessionEntry::Message {
5775                id: "a1".into(),
5776                parent_id: None,
5777                message: Message::Assistant(AssistantMessage {
5778                    content: vec![ContentBlock::Text {
5779                        text: "older answer".into(),
5780                    }],
5781                    usage: None,
5782                    stop_reason: StopReason::EndTurn,
5783                    timestamp: 0,
5784                }),
5785            })
5786            .unwrap();
5787        app.session
5788            .append(SessionEntry::Message {
5789                id: "u2".into(),
5790                parent_id: None,
5791                message: Message::user("recent request"),
5792            })
5793            .unwrap();
5794        app.session
5795            .append(SessionEntry::Compaction {
5796                id: "c1".into(),
5797                parent_id: None,
5798                summary: format!("{}summary body", COMPACTION_SUMMARY_PREFIX),
5799                first_kept_id: "u2".into(),
5800                tokens_before: 100,
5801                tokens_after: 40,
5802            })
5803            .unwrap();
5804
5805        app.load_session_messages();
5806
5807        assert_eq!(app.messages.len(), 2);
5808        assert_eq!(app.messages[0].role, MessageRole::Compaction);
5809        assert!(app.messages[0].content.contains("summary body"));
5810        assert_eq!(app.messages[1].role, MessageRole::User);
5811        assert_eq!(app.messages[1].content, "recent request");
5812    }
5813
5814    #[test]
5815    fn tui_integration_slash_quit_stops_app() {
5816        let mut app = make_app();
5817        assert!(app.running);
5818
5819        app.execute_command("quit");
5820        assert!(!app.running);
5821    }
5822
5823    #[test]
5824    fn tui_integration_slash_mouse_command_is_removed() {
5825        let mut app = make_app();
5826        // /mouse is no longer a recognized command — it should fall through to unknown
5827        app.execute_command("mouse");
5828        assert!(app
5829            .messages
5830            .last()
5831            .unwrap()
5832            .content
5833            .contains("Unknown command"));
5834    }
5835
5836    #[test]
5837    fn tui_integration_slash_unknown_shows_error() {
5838        let mut app = make_app();
5839
5840        app.execute_command("nonexistent");
5841
5842        assert_eq!(app.messages.len(), 1);
5843        assert_eq!(app.messages[0].role, MessageRole::Error);
5844        assert!(app.messages[0].content.contains("nonexistent"));
5845    }
5846
5847    #[test]
5848    fn command_palette_includes_checkpoint_commands() {
5849        let commands = builtin_commands();
5850        assert!(commands.iter().any(|cmd| cmd.name == "checkpoints"));
5851        assert!(commands.iter().any(|cmd| cmd.name == "restore-checkpoint"));
5852    }
5853
5854    #[test]
5855    fn execute_checkpoints_command_lists_recorded_checkpoints() {
5856        let tmp = TempDir::new().unwrap();
5857        let cwd = tmp.path().join("project");
5858        let session_dir = tmp.path().join("sessions");
5859        std::fs::create_dir_all(&cwd).unwrap();
5860        let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
5861        session
5862            .append_checkpoint_record(imp_core::session::SessionCheckpointRecord {
5863                version: imp_core::session::CHECKPOINT_RECORD_VERSION,
5864                checkpoint_id: "cp-1".into(),
5865                created_at: 123,
5866                label: Some("before edits".into()),
5867                files: vec!["src/main.rs".into()],
5868            })
5869            .unwrap();
5870
5871        let mut app = make_app_with_session(session, cwd.clone());
5872        app.execute_command("checkpoints");
5873        let last = app.messages.last().expect("system message");
5874        assert!(last.content.contains("cp-1"));
5875        assert!(last.content.contains("before edits"));
5876    }
5877
5878    #[test]
5879    fn execute_restore_checkpoint_command_reports_recorded_files() {
5880        let tmp = TempDir::new().unwrap();
5881        let cwd = tmp.path().join("project");
5882        let session_dir = tmp.path().join("sessions");
5883        std::fs::create_dir_all(&cwd).unwrap();
5884        let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
5885        session
5886            .append_checkpoint_record(imp_core::session::SessionCheckpointRecord {
5887                version: imp_core::session::CHECKPOINT_RECORD_VERSION,
5888                checkpoint_id: "cp-restore".into(),
5889                created_at: 123,
5890                label: Some("restore me".into()),
5891                files: vec!["src/main.rs".into(), "src/lib.rs".into()],
5892            })
5893            .unwrap();
5894
5895        let mut app = make_app_with_session(session, cwd.clone());
5896        app.execute_command("restore-checkpoint restore me");
5897        let last = app.messages.last().expect("system message");
5898        assert!(last.content.contains("cp-restore"));
5899        assert!(last.content.contains("src/main.rs"));
5900        assert!(last.content.contains("not wired yet"));
5901    }
5902
5903    #[tokio::test(flavor = "current_thread")]
5904    async fn agent_task_completion_preserves_active_replacement_handle() {
5905        let mut app = make_app();
5906        let (event_tx, event_rx) = tokio::sync::mpsc::channel(4);
5907        let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4);
5908        drop(event_tx);
5909
5910        app.agent_handle = Some(AgentHandle {
5911            event_rx,
5912            command_tx,
5913            cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5914        });
5915        app.agent_task = Some(tokio::spawn(async {
5916            tokio::time::sleep(Duration::from_secs(60)).await;
5917            Ok(())
5918        }));
5919
5920        app.handle_runtime_signal(RuntimeSignal::AgentTaskCompleted);
5921
5922        assert!(
5923            app.agent_handle.is_some(),
5924            "active replacement handle should survive stale completion"
5925        );
5926
5927        if let Some(task) = app.agent_task.take() {
5928            task.abort();
5929        }
5930    }
5931
5932    #[test]
5933    fn agent_task_completion_clears_handle_when_no_replacement_is_active() {
5934        let mut app = make_app();
5935        let (event_tx, event_rx) = tokio::sync::mpsc::channel(4);
5936        let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4);
5937        drop(event_tx);
5938
5939        app.agent_handle = Some(AgentHandle {
5940            event_rx,
5941            command_tx,
5942            cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5943        });
5944        app.agent_task = None;
5945
5946        app.handle_runtime_signal(RuntimeSignal::AgentTaskCompleted);
5947
5948        assert!(
5949            app.agent_handle.is_none(),
5950            "completed task should release handle when no replacement exists"
5951        );
5952    }
5953
5954    #[tokio::test(flavor = "current_thread")]
5955    async fn agent_task_failure_preserves_active_replacement_handle() {
5956        let mut app = make_app();
5957        let (event_tx, event_rx) = tokio::sync::mpsc::channel(4);
5958        let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4);
5959        drop(event_tx);
5960
5961        app.agent_handle = Some(AgentHandle {
5962            event_rx,
5963            command_tx,
5964            cancel_token: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5965        });
5966        app.agent_task = Some(tokio::spawn(async {
5967            tokio::time::sleep(Duration::from_secs(60)).await;
5968            Ok(())
5969        }));
5970
5971        app.handle_runtime_signal(RuntimeSignal::AgentTaskFailed("boom".into()));
5972
5973        assert!(
5974            app.agent_handle.is_some(),
5975            "active replacement handle should survive stale failure"
5976        );
5977        assert_eq!(
5978            app.messages.last().map(|m| m.role.clone()),
5979            Some(MessageRole::Error)
5980        );
5981
5982        if let Some(task) = app.agent_task.take() {
5983            task.abort();
5984        }
5985    }
5986
5987    #[tokio::test(flavor = "current_thread")]
5988    async fn esc_cancel_first_requests_cancel_second_aborts_stuck_agent_task() {
5989        let mut app = make_app();
5990        let (_event_tx, event_rx) = tokio::sync::mpsc::channel(4);
5991        let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(4);
5992        let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5993
5994        app.agent_handle = Some(AgentHandle {
5995            event_rx,
5996            command_tx,
5997            cancel_token: Arc::clone(&cancel_token),
5998        });
5999        app.agent_task = Some(tokio::spawn(async {
6000            tokio::time::sleep(Duration::from_secs(60)).await;
6001            Ok(())
6002        }));
6003        app.is_streaming = true;
6004        app.messages.push(DisplayMessage {
6005            role: MessageRole::Assistant,
6006            content: String::new(),
6007            thinking: None,
6008            tool_calls: Vec::new(),
6009            assistant_blocks: Vec::new(),
6010            is_streaming: true,
6011            timestamp: imp_llm::now(),
6012        });
6013
6014        app.handle_cancel();
6015
6016        assert!(cancel_token.load(std::sync::atomic::Ordering::Relaxed));
6017        assert!(matches!(command_rx.try_recv(), Ok(AgentCommand::Cancel)));
6018        assert!(
6019            app.agent_task.is_some(),
6020            "first Esc should allow graceful cancellation"
6021        );
6022        assert!(!app.is_streaming);
6023        assert!(!app.messages.last().unwrap().is_streaming);
6024
6025        app.handle_cancel();
6026
6027        assert!(
6028            app.agent_task.is_none(),
6029            "second Esc should abort a stuck task"
6030        );
6031        assert!(app.agent_handle.is_none());
6032    }
6033
6034    #[test]
6035    fn warning_notify_uses_system_role_not_error_role() {
6036        let mut app = make_app();
6037        app.handle_ui_request(crate::tui_interface::UiRequest::Notify {
6038            message: "Heads up".into(),
6039            level: imp_core::ui::NotifyLevel::Warning,
6040        });
6041
6042        let last = app.messages.last().expect("warning message");
6043        assert_eq!(last.role, MessageRole::Warning);
6044        assert_eq!(last.content, "Heads up");
6045    }
6046
6047    #[test]
6048    fn tool_updates_target_streaming_assistant_not_latest_message() {
6049        let mut app = make_app();
6050        app.messages.push(DisplayMessage {
6051            role: MessageRole::Assistant,
6052            content: String::new(),
6053            thinking: None,
6054            tool_calls: vec![DisplayToolCall {
6055                id: "tool-1".into(),
6056                name: "ask".into(),
6057                args_summary: "question=Pick one".into(),
6058                output: None,
6059                details: serde_json::Value::Null,
6060                is_error: false,
6061                expanded: false,
6062                streaming_lines: Vec::new(),
6063                streaming_output: String::new(),
6064            }],
6065            assistant_blocks: Vec::new(),
6066            is_streaming: true,
6067            timestamp: imp_llm::now(),
6068        });
6069        app.messages.push(DisplayMessage {
6070            role: MessageRole::System,
6071            content: "transient note".into(),
6072            thinking: None,
6073            tool_calls: Vec::new(),
6074            assistant_blocks: Vec::new(),
6075            is_streaming: false,
6076            timestamp: imp_llm::now(),
6077        });
6078
6079        app.handle_agent_event(AgentEvent::ToolExecutionStart {
6080            tool_call_id: "tool-1".into(),
6081            tool_name: "ask".into(),
6082            args: serde_json::json!({"question": "Pick one"}),
6083        });
6084        app.handle_agent_event(AgentEvent::ToolOutputDelta {
6085            tool_call_id: "tool-1".into(),
6086            text: "selected option".into(),
6087        });
6088        app.handle_agent_event(AgentEvent::ToolExecutionEnd {
6089            tool_call_id: "tool-1".into(),
6090            result: imp_llm::ToolResultMessage {
6091                tool_call_id: "tool-1".into(),
6092                tool_name: "ask".into(),
6093                content: vec![ContentBlock::Text {
6094                    text: "selected option".into(),
6095                }],
6096                is_error: false,
6097                details: serde_json::json!({}),
6098                timestamp: imp_llm::now(),
6099            },
6100        });
6101
6102        let assistant = app
6103            .messages
6104            .iter()
6105            .find(|msg| msg.role == MessageRole::Assistant)
6106            .expect("assistant message");
6107        assert_eq!(assistant.tool_calls.len(), 1);
6108        assert_eq!(
6109            assistant.tool_calls[0].output.as_deref(),
6110            Some("selected option")
6111        );
6112        assert!(!assistant.tool_calls[0].is_error);
6113
6114        let system = app.messages.last().expect("system message remains");
6115        assert_eq!(system.role, MessageRole::System);
6116        assert_eq!(system.content, "transient note");
6117    }
6118    #[test]
6119    fn tui_integration_slash_personality_opens_overlay() {
6120        let mut app = make_app();
6121        app.execute_command("personality");
6122        assert!(matches!(app.mode, UiMode::Personality(_)));
6123    }
6124
6125    #[test]
6126    fn tui_personality_prefers_ancestor_project_soul_when_opening() {
6127        let tmp = TempDir::new().unwrap();
6128        let project = tmp.path().join("project");
6129        let nested = project.join("src").join("deep");
6130        let session_dir = tmp.path().join("sessions");
6131        std::fs::create_dir_all(project.join(".imp")).unwrap();
6132        std::fs::create_dir_all(&nested).unwrap();
6133        std::fs::write(
6134            project.join(".imp").join("soul.md"),
6135            "# Soul\n\nproject soul\n",
6136        )
6137        .unwrap();
6138
6139        let session = SessionManager::new(&nested, &session_dir).unwrap();
6140        let mut app = make_app_with_session(session, nested.clone());
6141        app.execute_command("personality");
6142
6143        match &app.mode {
6144            UiMode::Personality(state) => {
6145                assert_eq!(state.current_path(), &project.join(".imp").join("soul.md"));
6146                assert!(matches!(state.scope, PersonalityScope::Project));
6147            }
6148            _ => panic!("expected personality mode"),
6149        }
6150    }
6151
6152    #[test]
6153    fn tui_integration_slash_memory_shows_stores() {
6154        let mut app = make_app();
6155
6156        app.execute_command("memory");
6157
6158        assert_eq!(app.messages.len(), 1);
6159        assert_eq!(app.messages[0].role, MessageRole::System);
6160        assert!(app.messages[0].content.contains("Memory ("));
6161        assert!(app.messages[0].content.contains("User profile ("));
6162    }
6163
6164    #[test]
6165    fn tui_integration_slash_memory_add_and_show() {
6166        let tmp = TempDir::new().unwrap();
6167        // Point config dir to temp so we don't touch real memory
6168        std::env::set_var("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
6169
6170        let mut app = make_app();
6171
6172        app.execute_command("memory add Test entry from slash command");
6173        assert!(app.messages.last().unwrap().content.contains("Added"));
6174
6175        // Show should list the entry
6176        app.execute_command("memory");
6177        let content = &app.messages.last().unwrap().content;
6178        assert!(content.contains("Test entry from slash command"));
6179
6180        // Clean up env var
6181        std::env::remove_var("XDG_CONFIG_HOME");
6182    }
6183
6184    #[test]
6185    fn tui_integration_slash_memory_help() {
6186        let mut app = make_app();
6187
6188        app.execute_command("memory help");
6189
6190        let content = &app.messages.last().unwrap().content;
6191        assert!(content.contains("/memory add"));
6192        assert!(content.contains("/memory remove"));
6193        assert!(content.contains("/memory clear"));
6194    }
6195
6196    #[test]
6197    fn tui_integration_slash_memory_unknown_subcommand() {
6198        let mut app = make_app();
6199
6200        app.execute_command("memory frobnicate");
6201
6202        let content = &app.messages.last().unwrap().content;
6203        assert!(content.contains("Unknown memory subcommand"));
6204        assert!(content.contains("frobnicate"));
6205    }
6206
6207    #[test]
6208    fn personality_state_default_sentence_is_visible() {
6209        let tmp = TempDir::new().unwrap();
6210        let state = crate::views::personality::PersonalityState::new(
6211            tmp.path().to_path_buf(),
6212            crate::views::personality::PersonalityScope::Global,
6213        );
6214        assert_eq!(
6215            state.sentence(),
6216            "You are imp, a practical, concise, coding agent."
6217        );
6218    }
6219
6220    #[test]
6221    fn tui_integration_slash_via_send_message() {
6222        let mut app = make_app();
6223
6224        // Type /new into editor and "send" — should route to execute_command
6225        app.editor.set_content("/new");
6226        app.send_message();
6227
6228        // /new clears messages, so display should be empty
6229        assert!(app.messages.is_empty());
6230        // Editor should be cleared
6231        assert!(app.editor.is_empty());
6232    }
6233
6234    // ── 4. Session reload on restart ────────────────────────────
6235
6236    #[test]
6237    fn tui_integration_session_reload_on_restart() {
6238        let tmp = TempDir::new().unwrap();
6239        let cwd = tmp.path().join("project");
6240        let session_dir = tmp.path().join("sessions");
6241
6242        // First "session": create and send messages
6243        let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
6244        let session_path = session.path().unwrap().to_path_buf();
6245        session
6246            .append(SessionEntry::Message {
6247                id: "m1".into(),
6248                parent_id: None,
6249                message: imp_llm::Message::user("first message"),
6250            })
6251            .unwrap();
6252        session
6253            .append(SessionEntry::Message {
6254                id: "m2".into(),
6255                parent_id: None,
6256                message: imp_llm::Message::user("second message"),
6257            })
6258            .unwrap();
6259
6260        // "Restart": open the session file and create a new App
6261        let reloaded_session = SessionManager::open(&session_path).unwrap();
6262        let config = Config::default();
6263        let registry = ModelRegistry::with_builtins();
6264        let mut app = App::new(config, reloaded_session, registry, cwd);
6265
6266        // Load persisted messages into display
6267        app.load_session_messages();
6268
6269        assert_eq!(app.messages.len(), 2);
6270        assert_eq!(app.messages[0].role, MessageRole::User);
6271        assert_eq!(app.messages[0].content, "first message");
6272        assert_eq!(app.messages[1].content, "second message");
6273    }
6274
6275    #[test]
6276    fn tui_integration_continue_recent_session() {
6277        let tmp = TempDir::new().unwrap();
6278        let cwd = tmp.path().join("project");
6279        let session_dir = tmp.path().join("sessions");
6280
6281        // Create a session for this cwd
6282        let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
6283        session
6284            .append(SessionEntry::Message {
6285                id: "m1".into(),
6286                parent_id: None,
6287                message: imp_llm::Message::user("continued"),
6288            })
6289            .unwrap();
6290        drop(session);
6291
6292        // Simulate --continue: find the most recent session for this cwd
6293        let continued = SessionManager::continue_recent(&cwd, &session_dir)
6294            .unwrap()
6295            .expect("should find a session");
6296        let config = Config::default();
6297        let registry = ModelRegistry::with_builtins();
6298        let mut app = App::new(config, continued, registry, cwd);
6299        app.load_session_messages();
6300
6301        assert_eq!(app.messages.len(), 1);
6302        assert_eq!(app.messages[0].content, "continued");
6303    }
6304
6305    // ── 5. Model switching ──────────────────────────────────────
6306
6307    #[test]
6308    fn tui_integration_model_switch_via_cycle() {
6309        let mut app = make_app();
6310        app.config.enabled_models = Some(
6311            app.model_registry
6312                .list()
6313                .iter()
6314                .take(3)
6315                .map(|m| m.id.clone())
6316                .collect(),
6317        );
6318
6319        // The default "sonnet" alias isn't a canonical ID, so cycle_model
6320        // starts from index 0.  After cycling forward, the model changes.
6321        let models = app.model_registry.list().to_vec();
6322        assert!(!models.is_empty());
6323
6324        app.cycle_model(true);
6325        let after_first = app.model_name.clone();
6326        // Should now be a canonical model ID from the registry
6327        assert!(
6328            models.iter().any(|m| m.id == after_first),
6329            "model_name should be a registered model after cycling"
6330        );
6331
6332        app.cycle_model(true);
6333        let after_second = app.model_name.clone();
6334        assert_ne!(
6335            after_first, after_second,
6336            "cycling again should pick a different model"
6337        );
6338
6339        // Cycling back returns to previous
6340        app.cycle_model(false);
6341        assert_eq!(app.model_name, after_first);
6342    }
6343
6344    #[test]
6345    fn tui_integration_model_switch_updates_context_window() {
6346        let mut app = make_app();
6347        app.config.enabled_models = Some(
6348            app.model_registry
6349                .list()
6350                .iter()
6351                .take(2)
6352                .map(|m| m.id.clone())
6353                .collect(),
6354        );
6355        let original_ctx = app.context_window;
6356
6357        // Cycle to a different model and check context_window updated
6358        app.cycle_model(true);
6359        let new_model = app.model_name.clone();
6360        let new_ctx = app.context_window;
6361
6362        let meta = app.model_registry.find_by_alias(&new_model).unwrap();
6363        assert_eq!(new_ctx, meta.context_window);
6364
6365        // If the new model has a different context window, verify it changed
6366        if meta.context_window != original_ctx {
6367            assert_ne!(new_ctx, original_ctx);
6368        }
6369    }
6370
6371    #[test]
6372    fn tui_integration_thinking_level_cycle() {
6373        let mut app = make_app();
6374        assert_eq!(app.thinking_level, ThinkingLevel::Medium);
6375
6376        app.cycle_thinking_level();
6377        assert_eq!(app.thinking_level, ThinkingLevel::High);
6378
6379        app.cycle_thinking_level();
6380        assert_eq!(app.thinking_level, ThinkingLevel::XHigh);
6381
6382        app.cycle_thinking_level();
6383        assert_eq!(app.thinking_level, ThinkingLevel::Off);
6384    }
6385
6386    // ── 6. Mouse click handling ─────────────────────────────────
6387
6388    #[test]
6389    fn app_starts_without_selection_state() {
6390        let app = make_app();
6391        assert!(app.selection.is_none());
6392        assert!(app.chat_surface.is_none());
6393        assert!(app.sidebar_list_rect.is_none());
6394    }
6395
6396    #[test]
6397    fn mouse_click_on_chat_area_starts_selection_instead_of_opening_sidebar() {
6398        let mut app = make_app();
6399
6400        // Simulate a message with a tool call
6401        app.messages.push(DisplayMessage {
6402            role: MessageRole::Assistant,
6403            content: "checking...".into(),
6404            thinking: None,
6405            tool_calls: vec![crate::views::tools::DisplayToolCall {
6406                id: "tc-42".into(),
6407                name: "bash".into(),
6408                args_summary: "$ ls".into(),
6409                output: Some("file1\nfile2".into()),
6410                details: serde_json::Value::Null,
6411                is_error: false,
6412                expanded: false,
6413                streaming_lines: Vec::new(),
6414                streaming_output: String::new(),
6415            }],
6416            assistant_blocks: Vec::new(),
6417            is_streaming: false,
6418            timestamp: 0,
6419        });
6420
6421        // Pre-populate chat surface; chat clicks now start selection instead of opening sidebar
6422        app.chat_surface = Some(TextSurface::new(
6423            SelectablePane::Chat,
6424            Rect::new(0, 0, 40, 5),
6425            vec!["checking...".into()],
6426            0,
6427        ));
6428
6429        // Simulate a mouse click at row 5
6430        let mouse = crossterm::event::MouseEvent {
6431            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6432            column: 10,
6433            row: 5,
6434            modifiers: KeyModifiers::empty(),
6435        };
6436        app.handle_mouse(mouse);
6437
6438        assert!(!app.sidebar.open);
6439        assert_eq!(app.active_pane, Pane::Chat);
6440        assert!(app.selection.is_some());
6441    }
6442
6443    #[test]
6444    fn mouse_click_on_sidebar_sets_focus() {
6445        let mut app = make_app();
6446        app.sidebar.open = true;
6447        app.sidebar_detail_rect = Some(Rect::new(50, 10, 30, 10));
6448
6449        app.sidebar_detail_surface = Some(TextSurface::new(
6450            SelectablePane::SidebarDetail,
6451            Rect::new(50, 12, 30, 8),
6452            vec!["detail".into()],
6453            0,
6454        ));
6455
6456        // Click inside sidebar detail
6457        let mouse = crossterm::event::MouseEvent {
6458            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6459            column: 60,
6460            row: 15,
6461            modifiers: KeyModifiers::empty(),
6462        };
6463        app.handle_mouse(mouse);
6464
6465        assert_eq!(app.active_pane, Pane::SidebarDetail);
6466    }
6467
6468    #[test]
6469    fn mouse_click_on_chat_area_sets_chat_focus() {
6470        let mut app = make_app();
6471        app.active_pane = Pane::SidebarDetail;
6472        app.sidebar_list_rect = Some(Rect::new(50, 1, 30, 5));
6473        app.sidebar_detail_rect = Some(Rect::new(50, 7, 30, 13));
6474
6475        // Click outside sidebar (in chat area)
6476        let mouse = crossterm::event::MouseEvent {
6477            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6478            column: 10,
6479            row: 10,
6480            modifiers: KeyModifiers::empty(),
6481        };
6482        app.handle_mouse(mouse);
6483
6484        assert_eq!(app.active_pane, Pane::Chat);
6485    }
6486
6487    #[test]
6488    fn keyboard_page_scroll_targets_chat_or_sidebar_detail() {
6489        let mut app = make_app();
6490        let lines = app.config.ui.keyboard_scroll_lines;
6491
6492        app.handle_normal_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()))
6493            .unwrap();
6494        assert_eq!(app.scroll_offset, lines);
6495        assert!(!app.auto_scroll);
6496        assert_eq!(app.sidebar.detail_scroll, 0);
6497
6498        app.sidebar.open = true;
6499        app.active_pane = Pane::SidebarDetail;
6500        app.handle_normal_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()))
6501            .unwrap();
6502        assert_eq!(app.sidebar.detail_scroll, 0);
6503        assert_eq!(app.scroll_offset, lines);
6504
6505        app.handle_normal_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()))
6506            .unwrap();
6507        assert_eq!(app.sidebar.detail_scroll, lines);
6508        assert_eq!(app.scroll_offset, lines);
6509
6510        app.active_pane = Pane::Chat;
6511        app.handle_normal_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()))
6512            .unwrap();
6513        assert_eq!(app.scroll_offset, 0);
6514        assert!(app.auto_scroll);
6515    }
6516
6517    #[test]
6518    fn ctrl_b_and_ctrl_f_map_to_page_scroll() {
6519        let mut app = make_app();
6520        let lines = app.config.ui.keyboard_scroll_lines;
6521
6522        app.handle_normal_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL))
6523            .unwrap();
6524        assert_eq!(app.scroll_offset, lines);
6525
6526        app.handle_normal_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL))
6527            .unwrap();
6528        assert_eq!(app.scroll_offset, 0);
6529    }
6530
6531    #[test]
6532    fn mouse_scroll_routes_by_position() {
6533        let mut app = make_app();
6534        // Use split mode so list and detail scroll independently
6535        app.config.ui.sidebar_style = imp_core::config::SidebarStyle::Split;
6536
6537        // Scroll up in chat area (no sidebar rects set)
6538        let mouse = crossterm::event::MouseEvent {
6539            kind: MouseEventKind::ScrollUp,
6540            column: 5,
6541            row: 5,
6542            modifiers: KeyModifiers::empty(),
6543        };
6544        app.handle_mouse(mouse);
6545        assert_eq!(app.scroll_offset, 3);
6546        assert!(!app.auto_scroll);
6547
6548        // Set up sidebar rects and scroll in detail area
6549        app.sidebar_detail_rect = Some(Rect::new(50, 5, 30, 15));
6550        app.sidebar.detail_scroll = 0;
6551        let mouse_detail = crossterm::event::MouseEvent {
6552            kind: MouseEventKind::ScrollDown,
6553            column: 60,
6554            row: 10,
6555            modifiers: KeyModifiers::empty(),
6556        };
6557        app.handle_mouse(mouse_detail);
6558        assert_eq!(app.sidebar.detail_scroll, 3);
6559        // Chat scroll should be unchanged
6560        assert_eq!(app.scroll_offset, 3);
6561
6562        // Scroll in list area
6563        app.sidebar_list_rect = Some(Rect::new(50, 0, 30, 5));
6564        app.sidebar.list_scroll = 0;
6565        let mouse_list = crossterm::event::MouseEvent {
6566            kind: MouseEventKind::ScrollDown,
6567            column: 60,
6568            row: 2,
6569            modifiers: KeyModifiers::empty(),
6570        };
6571        app.handle_mouse(mouse_list);
6572        assert_eq!(app.sidebar.list_scroll, 3);
6573    }
6574
6575    #[test]
6576    fn mouse_drag_in_chat_creates_selection() {
6577        let mut app = make_app();
6578        app.chat_surface = Some(TextSurface::new(
6579            SelectablePane::Chat,
6580            Rect::new(0, 0, 40, 5),
6581            vec!["hello world".into(), "second line".into()],
6582            0,
6583        ));
6584
6585        app.handle_mouse(crossterm::event::MouseEvent {
6586            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6587            column: 1,
6588            row: 0,
6589            modifiers: KeyModifiers::empty(),
6590        });
6591        app.handle_mouse(crossterm::event::MouseEvent {
6592            kind: MouseEventKind::Drag(crossterm::event::MouseButton::Left),
6593            column: 4,
6594            row: 0,
6595            modifiers: KeyModifiers::empty(),
6596        });
6597
6598        let selection = app.selection.clone().expect("selection created");
6599        assert_eq!(selection.pane, SelectablePane::Chat);
6600        let text = app.selection_text().unwrap();
6601        assert_eq!(text, "ello");
6602        assert_eq!(app.active_pane, Pane::Chat);
6603    }
6604
6605    #[test]
6606    fn selected_read_file_path_resolves_relative_path() {
6607        let cwd = PathBuf::from("/tmp/project");
6608        let tc = crate::views::tools::DisplayToolCall {
6609            id: "tc-read".into(),
6610            name: "read".into(),
6611            args_summary: "src/lib.rs".into(),
6612            output: Some("content".into()),
6613            details: serde_json::json!({ "path": "src/lib.rs" }),
6614            is_error: false,
6615            expanded: false,
6616            streaming_lines: Vec::new(),
6617            streaming_output: String::new(),
6618        };
6619
6620        let path = selected_read_file_path_from_tool(Some(&tc), &cwd).unwrap();
6621
6622        assert_eq!(path, cwd.join("src/lib.rs"));
6623    }
6624
6625    #[test]
6626    fn selected_read_file_path_ignores_non_read_tools() {
6627        let tc = crate::views::tools::DisplayToolCall {
6628            id: "tc-shell".into(),
6629            name: "shell".into(),
6630            args_summary: "cat src/lib.rs".into(),
6631            output: None,
6632            details: serde_json::json!({ "path": "src/lib.rs" }),
6633            is_error: false,
6634            expanded: false,
6635            streaming_lines: Vec::new(),
6636            streaming_output: String::new(),
6637        };
6638
6639        assert!(selected_read_file_path_from_tool(Some(&tc), Path::new("/tmp/project")).is_none());
6640    }
6641
6642    #[test]
6643    fn ctrl_o_without_read_selection_reports_no_file() {
6644        let mut app = make_app();
6645
6646        app.handle_normal_key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL))
6647            .unwrap();
6648
6649        assert!(app
6650            .messages
6651            .last()
6652            .unwrap()
6653            .content
6654            .contains("No read file selected"));
6655    }
6656
6657    #[test]
6658    fn inspector_defaults_to_latest_tool_when_no_focus() {
6659        let mut app = make_app();
6660        app.config.ui.sidebar_style = imp_core::config::SidebarStyle::Inspector;
6661        app.messages.push(DisplayMessage {
6662            role: MessageRole::Assistant,
6663            content: String::new(),
6664            thinking: None,
6665            tool_calls: vec![crate::views::tools::DisplayToolCall {
6666                id: "tc-latest".into(),
6667                name: "bash".into(),
6668                args_summary: "$ pwd".into(),
6669                output: Some("/tmp/test".into()),
6670                details: serde_json::Value::Null,
6671                is_error: false,
6672                expanded: false,
6673                streaming_lines: Vec::new(),
6674                streaming_output: String::new(),
6675            }],
6676            assistant_blocks: Vec::new(),
6677            is_streaming: false,
6678            timestamp: 0,
6679        });
6680
6681        let selected = app.selected_tool_call().expect("latest tool selected");
6682
6683        assert_eq!(selected.id, "tc-latest");
6684    }
6685
6686    #[test]
6687    fn focusing_tool_resets_inspector_scroll() {
6688        let mut app = make_app();
6689        app.config.ui.sidebar_style = imp_core::config::SidebarStyle::Inspector;
6690        app.sidebar.detail_scroll = 12;
6691
6692        app.focus_tool(0);
6693
6694        assert_eq!(app.tool_focus, Some(0));
6695        assert_eq!(app.active_pane, Pane::SidebarDetail);
6696        assert_eq!(app.sidebar.detail_scroll, 0);
6697    }
6698
6699    #[test]
6700    fn mouse_click_on_sidebar_list_selects_tool_for_review() {
6701        let mut app = make_app();
6702        app.sidebar.open = true;
6703        app.config.ui.sidebar_style = imp_core::config::SidebarStyle::Split;
6704        app.sidebar_list_rect = Some(Rect::new(50, 1, 30, 5));
6705        app.messages.push(DisplayMessage {
6706            role: MessageRole::Assistant,
6707            content: "checking...".into(),
6708            thinking: None,
6709            tool_calls: vec![crate::views::tools::DisplayToolCall {
6710                id: "tc-42".into(),
6711                name: "bash".into(),
6712                args_summary: "$ ls".into(),
6713                output: Some("file1\nfile2".into()),
6714                details: serde_json::Value::Null,
6715                is_error: false,
6716                expanded: false,
6717                streaming_lines: Vec::new(),
6718                streaming_output: String::new(),
6719            }],
6720            assistant_blocks: Vec::new(),
6721            is_streaming: false,
6722            timestamp: 0,
6723        });
6724
6725        app.handle_mouse(crossterm::event::MouseEvent {
6726            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6727            column: 60,
6728            row: 1,
6729            modifiers: KeyModifiers::empty(),
6730        });
6731
6732        assert_eq!(app.tool_focus, Some(0));
6733        assert_eq!(app.active_pane, Pane::SidebarList);
6734    }
6735
6736    #[test]
6737    fn shift_down_extends_selection_and_copy_shortcut_copies_it() {
6738        let mut app = make_app();
6739        app.selection = Some(SelectionState::new(
6740            SelectablePane::Chat,
6741            crate::selection::SelectionPos { line: 0, col: 0 },
6742            crate::selection::SelectionPos { line: 0, col: 0 },
6743        ));
6744        app.chat_surface = Some(TextSurface::new(
6745            SelectablePane::Chat,
6746            Rect::new(0, 0, 40, 5),
6747            vec!["one".into(), "two".into(), "three".into()],
6748            0,
6749        ));
6750
6751        app.handle_normal_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT))
6752            .unwrap();
6753        let selection = app.selection.clone().unwrap();
6754        assert_eq!(selection.focus.line, 1);
6755
6756        app.handle_normal_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))
6757            .unwrap();
6758        assert!(app
6759            .messages
6760            .last()
6761            .unwrap()
6762            .content
6763            .contains("Copied selection"));
6764    }
6765
6766    #[test]
6767    fn cmd_c_shortcut_is_treated_as_copy_when_selection_exists() {
6768        let mut app = make_app();
6769        app.selection = Some(SelectionState::new(
6770            SelectablePane::Chat,
6771            crate::selection::SelectionPos { line: 0, col: 0 },
6772            crate::selection::SelectionPos { line: 0, col: 0 },
6773        ));
6774        app.chat_surface = Some(TextSurface::new(
6775            SelectablePane::Chat,
6776            Rect::new(0, 0, 40, 5),
6777            vec!["one".into(), "two".into()],
6778            0,
6779        ));
6780
6781        app.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER))
6782            .unwrap();
6783
6784        assert!(app
6785            .messages
6786            .last()
6787            .unwrap()
6788            .content
6789            .contains("Copied selection"));
6790        assert_eq!(app.ctrl_c_count, 0);
6791    }
6792
6793    #[test]
6794    fn drag_near_chat_edge_enables_and_clears_autoscroll() {
6795        let mut app = make_app();
6796        app.chat_surface = Some(TextSurface::new(
6797            SelectablePane::Chat,
6798            Rect::new(0, 0, 40, 5),
6799            vec![
6800                "a".into(),
6801                "b".into(),
6802                "c".into(),
6803                "d".into(),
6804                "e".into(),
6805                "f".into(),
6806            ],
6807            0,
6808        ));
6809
6810        app.handle_mouse(crossterm::event::MouseEvent {
6811            kind: MouseEventKind::Down(crossterm::event::MouseButton::Left),
6812            column: 1,
6813            row: 1,
6814            modifiers: KeyModifiers::empty(),
6815        });
6816        app.handle_mouse(crossterm::event::MouseEvent {
6817            kind: MouseEventKind::Drag(crossterm::event::MouseButton::Left),
6818            column: 1,
6819            row: 4,
6820            modifiers: KeyModifiers::empty(),
6821        });
6822        assert!(app.drag_autoscroll.is_some());
6823
6824        app.handle_mouse(crossterm::event::MouseEvent {
6825            kind: MouseEventKind::Up(crossterm::event::MouseButton::Left),
6826            column: 1,
6827            row: 4,
6828            modifiers: KeyModifiers::empty(),
6829        });
6830        assert!(app.drag_autoscroll.is_none());
6831    }
6832
6833    #[test]
6834    fn build_click_map_with_tool_calls() {
6835        use crate::highlight::Highlighter;
6836        use crate::theme::Theme;
6837
6838        let theme = Theme::default();
6839        let highlighter = Highlighter::new();
6840
6841        let messages = vec![
6842            DisplayMessage {
6843                role: MessageRole::User,
6844                content: "do something".into(),
6845                thinking: None,
6846                tool_calls: Vec::new(),
6847                assistant_blocks: Vec::new(),
6848                is_streaming: false,
6849                timestamp: 0,
6850            },
6851            DisplayMessage {
6852                role: MessageRole::Assistant,
6853                content: "ok".into(),
6854                thinking: None,
6855                tool_calls: vec![
6856                    crate::views::tools::DisplayToolCall {
6857                        id: "tc-1".into(),
6858                        name: "read".into(),
6859                        args_summary: "file.rs".into(),
6860                        output: Some("contents".into()),
6861                        details: serde_json::Value::Null,
6862                        is_error: false,
6863                        expanded: false,
6864                        streaming_lines: Vec::new(),
6865                        streaming_output: String::new(),
6866                    },
6867                    crate::views::tools::DisplayToolCall {
6868                        id: "tc-2".into(),
6869                        name: "edit".into(),
6870                        args_summary: "file.rs".into(),
6871                        output: Some("done".into()),
6872                        details: serde_json::Value::Null,
6873                        is_error: false,
6874                        expanded: false,
6875                        streaming_lines: Vec::new(),
6876                        streaming_output: String::new(),
6877                    },
6878                ],
6879                assistant_blocks: Vec::new(),
6880                is_streaming: false,
6881                timestamp: 0,
6882            },
6883        ];
6884
6885        // Large chat area so everything is visible
6886        let area = Rect::new(0, 0, 80, 50);
6887        let click_map = crate::views::chat::build_click_map(
6888            &messages,
6889            &theme,
6890            &highlighter,
6891            area,
6892            0,
6893            true,
6894            imp_core::config::ChatToolDisplay::Interleaved,
6895            5,
6896            false,
6897        );
6898
6899        // Should have 2 entries (one per tool call)
6900        assert_eq!(click_map.len(), 2);
6901        assert_eq!(click_map[0].1, "tc-1");
6902        assert_eq!(click_map[1].1, "tc-2");
6903        assert_eq!(click_map[1].0, click_map[0].0 + 1);
6904    }
6905
6906    #[test]
6907    fn resumed_session_attaches_tool_results_persisted_before_assistant() {
6908        let tmp = TempDir::new().unwrap();
6909        let cwd = tmp.path().join("project");
6910        let session_dir = tmp.path().join("sessions");
6911
6912        let mut session = SessionManager::new(&cwd, &session_dir).unwrap();
6913        let session_path = session.path().unwrap().to_path_buf();
6914
6915        let tool_result = imp_llm::ToolResultMessage {
6916            tool_call_id: "tc-1".into(),
6917            tool_name: "mana".into(),
6918            content: vec![imp_llm::ContentBlock::Text {
6919                text: "Invalid priority: 5".into(),
6920            }],
6921            is_error: true,
6922            details: serde_json::Value::Null,
6923            timestamp: imp_llm::now(),
6924        };
6925
6926        let assistant = imp_llm::AssistantMessage {
6927            content: vec![
6928                imp_llm::ContentBlock::Text {
6929                    text: "Trying mana create".into(),
6930                },
6931                imp_llm::ContentBlock::ToolCall {
6932                    id: "tc-1".into(),
6933                    name: "mana".into(),
6934                    arguments: serde_json::json!({"action": "create", "priority": 5}),
6935                },
6936            ],
6937            usage: None,
6938            stop_reason: imp_llm::StopReason::ToolUse,
6939            timestamp: imp_llm::now(),
6940        };
6941
6942        // Persist in the same order the runtime can produce: tool_result before assistant turn end.
6943        session
6944            .append(SessionEntry::Message {
6945                id: "tr1".into(),
6946                parent_id: None,
6947                message: imp_llm::Message::ToolResult(tool_result),
6948            })
6949            .unwrap();
6950        session
6951            .append(SessionEntry::Message {
6952                id: "a1".into(),
6953                parent_id: None,
6954                message: imp_llm::Message::Assistant(assistant),
6955            })
6956            .unwrap();
6957
6958        let reopened = SessionManager::open(&session_path).unwrap();
6959        let config = Config::default();
6960        let registry = ModelRegistry::with_builtins();
6961        let mut app = App::new(config, reopened, registry, cwd);
6962        app.load_session_messages();
6963
6964        let tool_calls: Vec<&crate::views::tools::DisplayToolCall> = app
6965            .messages
6966            .iter()
6967            .flat_map(|m| m.tool_calls.iter())
6968            .collect();
6969
6970        assert_eq!(tool_calls.len(), 1);
6971        assert_eq!(tool_calls[0].id, "tc-1");
6972        assert_eq!(tool_calls[0].output.as_deref(), Some("Invalid priority: 5"));
6973        assert!(tool_calls[0].is_error);
6974    }
6975
6976    #[test]
6977    fn agent_end_does_not_double_count_usage_or_overwrite_context() {
6978        let mut app = make_app();
6979        let turn_usage = Usage {
6980            input_tokens: 500_000,
6981            output_tokens: 25_000,
6982            cache_read_tokens: 10_000,
6983            ..Usage::default()
6984        };
6985        let assistant = imp_llm::AssistantMessage {
6986            content: vec![imp_llm::ContentBlock::Text {
6987                text: "done".into(),
6988            }],
6989            usage: Some(turn_usage.clone()),
6990            stop_reason: imp_llm::StopReason::EndTurn,
6991            timestamp: 0,
6992        };
6993
6994        app.handle_agent_event(AgentEvent::TurnEnd {
6995            index: 0,
6996            message: assistant,
6997            mana_review: imp_core::mana_review::TurnManaReview::no_change(0),
6998        });
6999        app.handle_agent_event(AgentEvent::AgentEnd {
7000            usage: Usage {
7001                input_tokens: 1_000_000,
7002                output_tokens: 50_000,
7003                ..Usage::default()
7004            },
7005            cost: Cost {
7006                input: 1.0,
7007                output: 2.0,
7008                cache_read: 0.0,
7009                cache_write: 0.0,
7010                total: 3.0,
7011            },
7012        });
7013
7014        assert_eq!(app.current_context_tokens, 510_000);
7015        assert_eq!(app.accumulated_usage.input_tokens, 500_000);
7016        assert_eq!(app.accumulated_usage.output_tokens, 25_000);
7017        assert_eq!(app.accumulated_cost.total, 3.0);
7018    }
7019
7020    #[test]
7021    fn completion_bell_requires_completed_turn_and_resets_latch() {
7022        let mut app = make_app();
7023        app.config.ui.notify_on_agent_complete = true;
7024
7025        app.maybe_notify_agent_completion();
7026        assert_eq!(app.completed_turns_in_run, 0);
7027
7028        app.completed_turns_in_run = 2;
7029        app.maybe_notify_agent_completion();
7030        assert_eq!(app.completed_turns_in_run, 0);
7031    }
7032
7033    #[test]
7034    fn completion_bell_toggle_still_resets_latch() {
7035        let mut app = make_app();
7036        app.config.ui.notify_on_agent_complete = false;
7037        app.completed_turns_in_run = 1;
7038
7039        app.maybe_notify_agent_completion();
7040
7041        assert_eq!(app.completed_turns_in_run, 0);
7042    }
7043
7044    #[test]
7045    fn completion_bell_cancel_suppresses_notification_once() {
7046        let mut app = make_app();
7047        app.config.ui.notify_on_agent_complete = true;
7048        app.completed_turns_in_run = 1;
7049        app.suppress_completion_notification = true;
7050
7051        app.maybe_notify_agent_completion();
7052
7053        assert_eq!(app.completed_turns_in_run, 0);
7054        assert!(!app.suppress_completion_notification);
7055    }
7056
7057    #[test]
7058    fn handle_ui_request_stores_and_removes_widgets() {
7059        let mut app = make_app();
7060
7061        app.handle_ui_request(crate::tui_interface::UiRequest::SetWidget {
7062            key: "mana".into(),
7063            content: Some(imp_core::ui::WidgetContent::Lines(vec![
7064                "running unit 1".into(),
7065                "inspect with mana agents".into(),
7066            ])),
7067        });
7068
7069        assert!(app.widgets.contains_key("mana"));
7070
7071        app.handle_ui_request(crate::tui_interface::UiRequest::SetWidget {
7072            key: "mana".into(),
7073            content: None,
7074        });
7075
7076        assert!(!app.widgets.contains_key("mana"));
7077    }
7078
7079    #[test]
7080    fn custom_ui_request_returns_none_without_panicking() {
7081        let mut app = make_app();
7082        let (tx, mut rx) = tokio::sync::oneshot::channel();
7083        app.handle_ui_request(crate::tui_interface::UiRequest::Custom {
7084            component: imp_core::ui::ComponentSpec {
7085                component_type: "mana-widget".into(),
7086                props: serde_json::json!({"state": "running"}),
7087                children: Vec::new(),
7088            },
7089            reply: tx,
7090        });
7091
7092        assert_eq!(rx.try_recv().ok().flatten(), None);
7093    }
7094}