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