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 pub running: bool,
258 pub messages: Vec<DisplayMessage>,
259 pub editor: EditorState,
260 ask_editor_backup: Option<EditorState>,
261 pub cwd: PathBuf,
262
263 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 pub session: SessionManager,
271
272 pub config: Config,
274 pub model_name: String,
275 pub thinking_level: ThinkingLevel,
276 pub context_window: u32,
277
278 pub mode: UiMode,
280 pub scroll_offset: usize,
281 pub auto_scroll: bool,
282 pub tools_expanded: bool,
283 pub tool_focus: Option<usize>,
285 pub tool_focus_pinned: bool,
287 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 pub accumulated_usage: Usage,
306 pub accumulated_cost: Cost,
307 pub current_context_tokens: u32,
309 chat_render_epoch: u64,
310
311 pub status_items: HashMap<String, String>,
313 pub widgets: HashMap<String, WidgetContent>,
314
315 pub lua_runtime: Option<Arc<Mutex<LuaRuntime>>>,
317
318 pub sidebar: Sidebar,
320
321 pub active_pane: Pane,
323 pub sidebar_list_rect: Option<Rect>,
325 pub sidebar_detail_rect: Option<Rect>,
327 pub chat_surface: Option<TextSurface>,
329 pub sidebar_detail_surface: Option<TextSurface>,
331 pub selection: Option<SelectionState>,
333 pub drag_selection: Option<SelectablePane>,
335 drag_autoscroll: Option<DragAutoScroll>,
337 chat_render_cache: Option<ChatRenderCache>,
339 sidebar_stream_cache: Option<SidebarStreamCache>,
340 sidebar_detail_cache: Option<SidebarDetailCache>,
341
342 pub turn_tracker: TurnTracker,
344
345 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 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 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 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 self.reload_lua_extensions();
727
728 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 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 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 self.pump_runtime_signals().await;
775
776 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 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 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 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 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), Constraint::Length(editor_height), ];
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 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 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 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 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 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 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 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 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 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 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 if self.ask_state.is_some() {
1838 self.handle_ask_key(key);
1839 return Ok(());
1840 }
1841
1842 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 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 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 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 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 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 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 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 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 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 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 fn total_tool_calls(&self) -> usize {
2381 self.messages.iter().map(|m| m.tool_calls.len()).sum()
2382 }
2383
2384 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 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 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 self.ctrl_c_count += 1;
2875 if self.ctrl_c_count >= 2 {
2876 self.running = false;
2877 }
2878 }
2879 }
2880
2881 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 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 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 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 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 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 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 if let Some(cmd_text) = text.strip_prefix('/') {
2985 let typed = cmd_text.trim();
2986 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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) => {} 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 },
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 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 let _ = tx.send(indices.first().copied());
4012 }
4013 (AskResult::Text(text), Some(AskReply::Select(tx))) => {
4014 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 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 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 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 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 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 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 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 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 let state = match &self.mode {
4658 UiMode::Settings(s) => s.clone(),
4659 _ => return,
4660 };
4661
4662 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 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 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 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 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 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 if let Some(last) = self.latest_streaming_message_mut() {
5110 last.is_streaming = false;
5111 }
5112 self.invalidate_chat_render_cache();
5113
5114 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 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 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 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 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 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 if let Some(tc) = self.find_tool_call_mut(&tool_call_id) {
5215 if !tc.streaming_output.is_empty() {
5217 tc.streaming_output.push('\n');
5218 }
5219 tc.streaming_output.push_str(&text);
5220 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 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 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 if is_error {
5264 tc.expanded = inline_output_enabled;
5265 }
5266 }
5267
5268 self.invalidate_chat_render_cache();
5269
5270 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 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 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 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 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
5342fn 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
5365fn 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
5373fn 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 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 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 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(®istry, &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(®istry, &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(®istry, &Config::default(), &auth_store);
5514 assert!(models.is_empty());
5515
5516 let (models, current_model) = include_current_model_option(models, ®istry, "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 #[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 assert!(app.session.path().is_some());
5597 assert!(app.session.path().unwrap().exists());
5598 }
5599
5600 #[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 app.editor.set_content("hello world");
5609 app.send_message();
5610
5611 let messages = app.session.get_messages();
5613 assert_eq!(messages.len(), 1);
5614 assert!(messages[0].is_user());
5615
5616 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 app.send_message();
5656 assert!(app.messages.is_empty());
5657 assert_eq!(app.session.get_messages().len(), 0);
5658
5659 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 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 #[test]
5684 fn tui_integration_slash_new_clears_session() {
5685 let mut app = make_app();
5686
5687 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 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 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 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 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 app.execute_command("memory");
6177 let content = &app.messages.last().unwrap().content;
6178 assert!(content.contains("Test entry from slash command"));
6179
6180 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 app.editor.set_content("/new");
6226 app.send_message();
6227
6228 assert!(app.messages.is_empty());
6230 assert!(app.editor.is_empty());
6232 }
6233
6234 #[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 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 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 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 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 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 #[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 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 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 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 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 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 #[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 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 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 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 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 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 app.config.ui.sidebar_style = imp_core::config::SidebarStyle::Split;
6536
6537 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 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 assert_eq!(app.scroll_offset, 3);
6561
6562 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 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 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 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}