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