1use crossterm::event::{KeyCode, KeyModifiers};
41use std::collections::VecDeque;
42use std::fmt::Debug;
43use std::io::{self, Write};
44use std::sync::Arc;
45use std::task::Poll;
46use std::time::{Duration, Instant};
47
48use futures::future::{BoxFuture, poll_fn};
49use pi_ai::{AssistantMessage, ImageContent};
50use pi_tui::component::{Component, EventResult, UiEvent};
51use pi_tui::components::editor::{Editor, EditorOptions};
52use pi_tui::keys::{ParsedKeyId, encode_key_event, key_matches_parsed, parse_key_id};
53use pi_tui::terminal::caps::TerminalCapabilities;
54use pi_tui::terminal::input::TerminalInput;
55use pi_tui::terminal::writer::{ReanchorCause, SettledBlock, Tui, Txn};
56use ratatui::buffer::Buffer;
57use ratatui::layout::Rect;
58use ratatui::text::Line;
59use tokio::sync::{Notify, mpsc, oneshot, watch};
60use tokio::task::{JoinError, JoinSet};
61use tokio_util::sync::CancellationToken;
62
63use crate::core::agent_session::events::AgentSessionEvent;
64use crate::core::agent_session::extension_runner::ExtensionRunner;
65use crate::core::agent_session::prompt::{PromptOptions, StreamingBehavior};
66use crate::core::extension_host::{ExtensionUiEvent, HostExtensionRunner};
67use crate::core::migrations::MigrationResult;
68use crate::core::platform::external_editor::{EditOutcome, edit_text_in_external_editor};
69use pi_ext::client::{HostUiRequest, HostUiResponse};
70use pi_ext::protocol::{
71 KeyEventKindWire, KeyModifiersWire, NotifyLevel, SlotPlacement, UiEventRequest, UiEventWire,
72};
73use pi_ext::sanitize::SanitizedSlot;
74
75use super::input::{DoubleEscapeAction, InputMapper, InputState};
76use super::messages::{AssistantMessageView, MessageView};
77#[cfg(test)]
78use super::state;
79use super::state::{
80 BillingMode, DiagnosticSeverity, EditorBorder, FocusArea, Overlay, OverlayKind, PendingKind,
81 PendingMessage, SessionStatus, StartupDiagnostic, StatusKind, ViewAction, ViewState,
82 WidgetSlot,
83};
84use super::theme::ResolvedTheme;
85use super::view::{ComposedSection, compose};
86
87pub const DRAW_TIMEOUT: Duration = Duration::from_secs(5);
95
96pub const BACKGROUND_COALESCE_WINDOW: Duration = Duration::from_millis(16);
98
99pub const EVENT_CHANNEL_CAPACITY: usize = 256;
102
103#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub enum SessionActivity {
110 #[default]
112 Idle,
113 Streaming,
115 Compacting,
117 Retrying,
119 Summarizing,
121}
122
123#[derive(Clone, Debug, Default)]
128pub struct SessionSnapshot {
129 pub activity: SessionActivity,
131 pub admission_active: bool,
133 pub bash_running: bool,
135 pub thinking_level_label: String,
137 pub model_id: String,
139 pub reasoning: bool,
141 pub steering: Vec<String>,
143 pub follow_up: Vec<String>,
145 pub follow_up_mode: super::state::QueueMode,
147}
148
149#[derive(Clone, Debug, PartialEq)]
151pub struct SessionFooterSnapshot {
152 pub total_input: u64,
154 pub total_output: u64,
156 pub total_cache_read: u64,
158 pub total_cache_write: u64,
160 pub total_cost: f64,
162 pub context_window: u64,
164 pub context_percent: Option<f64>,
166 pub provider: Option<String>,
168 pub provider_count: usize,
170 pub thinking_level: pi_ai::ModelThinkingLevel,
172 pub bash_running: bool,
174 pub subscription: bool,
176 pub auto_compact: bool,
178}
179
180impl Default for SessionFooterSnapshot {
181 fn default() -> Self {
182 Self {
183 total_input: 0,
184 total_output: 0,
185 total_cache_read: 0,
186 total_cache_write: 0,
187 total_cost: 0.0,
188 context_window: 0,
189 context_percent: None,
190 provider: None,
191 provider_count: 0,
192 thinking_level: pi_ai::ModelThinkingLevel::Off,
193 bash_running: false,
194 subscription: false,
195 auto_compact: true,
196 }
197 }
198}
199
200impl SessionSnapshot {
201 #[must_use]
203 pub fn is_admission_active(&self) -> bool {
204 self.admission_active
205 }
206}
207
208pub type ScopedModelEntries = (
210 Vec<super::state::ModelSelectorEntry>,
211 std::collections::BTreeMap<String, bool>,
212);
213
214pub trait SessionHost: Send + Sync + 'static {
231 fn snapshot(&self) -> SessionSnapshot;
233
234 fn footer_snapshot(&self) -> BoxFuture<'_, SessionFooterSnapshot> {
236 Box::pin(std::future::ready(SessionFooterSnapshot::default()))
237 }
238
239 fn subscribe(&self) -> EventSubscription;
242
243 fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>>;
245
246 fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>>;
250
251 fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>>;
253
254 fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>>;
256
257 fn abort(&self) -> BoxFuture<'static, Result<(), String>>;
263
264 fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>>;
266
267 fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>>;
269
270 fn cycle_model(&self, forward: bool) -> BoxFuture<'_, Result<(), String>>;
272
273 fn reload(&self) -> BoxFuture<'_, Result<(), String>>;
275
276 fn messages(&self) -> Vec<pi_agent::AgentMessage>;
278
279 fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
281 None
282 }
283
284 fn hide_thinking_block(&self) -> bool {
286 false
287 }
288
289 fn set_hide_thinking_block(&self, _hide: bool) -> Result<(), String> {
295 Ok(())
296 }
297
298 fn external_editor_command(&self) -> String {
300 if cfg!(windows) {
301 "notepad".to_owned()
302 } else {
303 "nano".to_owned()
304 }
305 }
306
307 fn get_model_entries(
309 &self,
310 ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>>;
311
312 fn get_session_entries(
314 &self,
315 ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>>;
316
317 fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>>;
319
320 fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>>;
322
323 fn get_trust_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
325
326 fn get_auth_entries(
328 &self,
329 ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>>;
330
331 fn get_scoped_models_entries(&self) -> BoxFuture<'_, Result<ScopedModelEntries, String>>;
333
334 fn get_settings_entries(&self)
336 -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
337
338 fn get_config_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>>;
340
341 fn execute_bash(
344 &self,
345 command: &str,
346 exclude_from_context: bool,
347 ) -> BoxFuture<'_, Result<(), String>>;
348
349 fn new_session(&self) -> BoxFuture<'_, Result<(), String>>;
351
352 fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>>;
354
355 fn clone(&self) -> BoxFuture<'_, Result<(), String>>;
357
358 fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>>;
360
361 fn export_html(&self, path: Option<&str>) -> BoxFuture<'_, Result<String, String>>;
363
364 fn set_session_name(&self, name: &str) -> BoxFuture<'_, Result<(), String>>;
366
367 fn logout(&self) -> BoxFuture<'_, Result<(), String>>;
369
370 fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>>;
373}
374
375pub struct EventSubscription {
380 pub rx: mpsc::UnboundedReceiver<AgentSessionEvent>,
382 pub unsubscribe: Option<Box<dyn FnOnce() + Send + Sync>>,
384}
385
386impl Debug for EventSubscription {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 f.debug_struct("EventSubscription").finish_non_exhaustive()
389 }
390}
391
392impl Drop for EventSubscription {
393 fn drop(&mut self) {
394 if let Some(unsub) = self.unsubscribe.take() {
395 unsub();
396 }
397 }
398}
399
400#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405pub enum InteractiveExit {
406 Clean,
408 IoFailure,
410 DrawDeadlock,
412 SessionEnded,
414 Suspend,
418 ExternalEditor,
420}
421
422pub struct InteractiveRuntimeOptions {
424 pub theme: Arc<ResolvedTheme>,
426 pub caps: TerminalCapabilities,
428 pub size: (u16, u16),
430 pub viewport_height: u16,
432 pub quiet: bool,
434 pub double_escape: DoubleEscapeAction,
436 pub hardware_cursor: bool,
438 pub initial_message: Option<String>,
440 pub initial_images: Vec<ImageContent>,
442 pub remaining_messages: Vec<String>,
444 pub migrations: MigrationResult,
446}
447
448#[derive(Clone, Copy, Debug, PartialEq, Eq)]
450pub(crate) enum ActionOutcome {
451 None,
453 Repaint,
455 Exit,
457 Suspend,
459 ExternalEditor,
461}
462
463#[derive(Clone, Copy, Debug, PartialEq, Eq)]
464enum TypedBuiltin<'a> {
465 Compact(Option<&'a str>),
466 Fork,
467 Resume,
468 Reload,
469}
470
471#[derive(Clone, Copy, Debug, PartialEq, Eq)]
472enum SessionReplacement {
473 New,
474 Fork,
475 Clone,
476}
477
478fn parse_typed_builtin(text: &str) -> Option<TypedBuiltin<'_>> {
479 match text {
480 "/compact" => Some(TypedBuiltin::Compact(None)),
481 "/fork" => Some(TypedBuiltin::Fork),
482 "/resume" => Some(TypedBuiltin::Resume),
483 "/reload" => Some(TypedBuiltin::Reload),
484 _ => text
485 .strip_prefix("/compact ")
486 .map(str::trim)
487 .map(Some)
488 .map(TypedBuiltin::Compact),
489 }
490}
491
492impl Default for InteractiveRuntimeOptions {
493 fn default() -> Self {
494 Self {
495 theme: super::theme::dark(),
496 caps: TerminalCapabilities::default(),
497 size: (80, 24),
498 viewport_height: 24,
499 quiet: false,
500 double_escape: DoubleEscapeAction::None,
501 hardware_cursor: false,
502 initial_message: None,
503 initial_images: Vec::new(),
504 remaining_messages: Vec::new(),
505 migrations: MigrationResult::default(),
506 }
507 }
508}
509
510struct InteractiveRoot {
512 pre_editor: Vec<ComposedSection>,
513 editor: Editor,
514 post_editor: Vec<ComposedSection>,
515 overlay: Option<Box<dyn Component>>,
516 overlay_spec: Option<pi_tui::layout::OverlaySpec>,
517 selector: Option<Box<dyn Component>>,
518 dialog_title: Option<Box<dyn Component>>,
519 focus: FocusArea,
520}
521
522impl InteractiveRoot {
523 #[cfg(test)]
524 fn build(view: &ViewState, editor: Editor, selector: Option<Box<dyn Component>>) -> Self {
525 let composed = compose(view);
526 let mut sections = composed.sections;
527 let editor_idx = sections
528 .iter()
529 .position(|section| section.label == "editor")
530 .unwrap_or(sections.len().saturating_sub(1));
531 let pre_editor: Vec<_> = sections.drain(0..editor_idx).collect();
532 if !sections.is_empty() {
533 sections.remove(0);
534 }
535 Self {
536 pre_editor,
537 editor,
538 post_editor: sections,
539 overlay: composed.overlay,
540 overlay_spec: composed.overlay_spec,
541 selector,
542 dialog_title: None,
543 focus: view.focus,
544 }
545 }
546
547 fn build_with_chat(
548 view: &mut ViewState,
549 editor: Editor,
550 selector: Option<Box<dyn Component>>,
551 dialog_title: Option<Box<dyn Component>>,
552 prefix: Box<dyn Component>,
553 tail: Box<dyn Component>,
554 ) -> Self {
555 let messages = std::mem::take(&mut view.messages);
556 let mut composed = compose(view);
557 view.messages = messages;
558 if let Some(index) = composed
559 .sections
560 .iter()
561 .position(|section| section.label == "chat")
562 {
563 composed.sections[index] = ComposedSection {
564 label: "chat-prefix",
565 component: prefix,
566 };
567 composed.sections.insert(
568 index + 1,
569 ComposedSection {
570 label: "chat-tail",
571 component: tail,
572 },
573 );
574 }
575 let mut sections = composed.sections;
576 let editor_idx = sections
577 .iter()
578 .position(|section| section.label == "editor")
579 .unwrap_or(sections.len().saturating_sub(1));
580 let pre_editor: Vec<_> = sections.drain(..editor_idx).collect();
581 let overlay = composed.overlay;
582 let overlay_spec = composed.overlay_spec;
583 if !sections.is_empty() {
584 sections.remove(0);
585 }
586 Self {
587 pre_editor,
588 editor,
589 post_editor: sections,
590 overlay,
591 overlay_spec,
592 selector,
593 dialog_title,
594 focus: view.focus,
595 }
596 }
597
598 fn take_section(&mut self, label: &'static str) -> Option<Box<dyn Component>> {
599 let section = self
600 .pre_editor
601 .iter_mut()
602 .find(|section| section.label == label)?;
603 Some(std::mem::replace(
604 &mut section.component,
605 Box::new(pi_tui::components::Text::new(String::new())),
606 ))
607 }
608
609 fn editor_mut(&mut self) -> &mut Editor {
610 &mut self.editor
611 }
612
613 fn render_middle(
614 &mut self,
615 area: Rect,
616 y: u16,
617 height: u16,
618 title_height: u16,
619 buf: &mut Buffer,
620 ) {
621 if height == 0 {
622 return;
623 }
624 let rendered_title_height = title_height.min(height);
625 if rendered_title_height > 0
626 && let Some(title) = self.dialog_title.as_mut()
627 {
628 title.render(Rect::new(area.x, y, area.width, rendered_title_height), buf);
629 }
630 let body_height = height.saturating_sub(rendered_title_height);
631 if body_height == 0 {
632 return;
633 }
634 let body_area = Rect::new(
635 area.x,
636 y.saturating_add(rendered_title_height),
637 area.width,
638 body_height,
639 );
640 if self.focus == FocusArea::Selector {
641 if let Some(selector) = self.selector.as_mut() {
642 selector.render(body_area, buf);
643 }
644 } else {
645 self.editor.render(body_area, buf);
646 }
647 }
648
649 fn render_overlay(&mut self, area: Rect, buf: &mut Buffer) {
650 let Some(overlay) = self.overlay.as_mut() else {
651 return;
652 };
653 let measured = overlay.measure(area.width).min(area.height);
654 let overlay_area = self.overlay_spec.as_ref().map_or_else(
655 || Rect::new(area.x, area.y, area.width, measured),
656 |spec| {
657 let layout =
658 pi_tui::layout::resolve_overlay_layout(spec, measured, area.width, area.height);
659 let overlay_height = layout
660 .max_height
661 .map_or(measured, |max_height| measured.min(max_height))
662 .min(area.height.saturating_sub(layout.row));
663 Rect::new(
664 area.x.saturating_add(layout.col),
665 area.y.saturating_add(layout.row),
666 layout.width,
667 overlay_height,
668 )
669 },
670 );
671 if overlay_area.height > 0 {
672 overlay.render(overlay_area, buf);
673 }
674 }
675}
676
677fn visible_suffix(heights: &[u16], available: u16) -> (usize, u16) {
678 let mut used = 0_u16;
679 let mut start = heights.len();
680 let mut skipped_rows = 0_u16;
681 for (index, &height) in heights.iter().enumerate().rev() {
682 if used == available {
683 break;
684 }
685 start = index;
686 let remaining = available - used;
687 if height > remaining {
688 skipped_rows = height - remaining;
689 break;
690 }
691 used += height;
692 }
693 (start, skipped_rows)
694}
695
696fn render_bottom_clipped(
697 component: &mut dyn Component,
698 area: Rect,
699 measured_height: u16,
700 skipped_rows: u16,
701 buf: &mut Buffer,
702) {
703 if area.is_empty() {
704 return;
705 }
706 if skipped_rows == 0 {
707 component.render(area, buf);
708 return;
709 }
710
711 let source_area = Rect::new(0, 0, area.width, measured_height);
712 let mut source = Buffer::empty(source_area);
713 component.render(source_area, &mut source);
714 for row in 0..area.height {
715 for column in 0..area.width {
716 let source_position = (column, skipped_rows + row);
717 let target_position = (area.x + column, area.y + row);
718 if let (Some(source_cell), Some(target_cell)) =
719 (source.cell(source_position), buf.cell_mut(target_position))
720 {
721 *target_cell = source_cell.clone();
722 }
723 }
724 }
725}
726
727impl Component for InteractiveRoot {
728 fn measure(&mut self, width: u16) -> u16 {
729 let pre_height = self.pre_editor.iter_mut().fold(0_u16, |height, section| {
730 height.saturating_add(section.component.measure(width))
731 });
732 let title_height = self
733 .dialog_title
734 .as_mut()
735 .map_or(0, |title| title.measure(width));
736 let body_height = if self.focus == FocusArea::Selector {
737 self.selector
738 .as_mut()
739 .map_or(0, |selector| selector.measure(width))
740 } else {
741 self.editor.measure(width)
742 };
743 let middle_height = title_height.saturating_add(body_height);
744 self.post_editor.iter_mut().fold(
745 pre_height.saturating_add(middle_height),
746 |height, section| height.saturating_add(section.component.measure(width)),
747 )
748 }
749
750 fn render(&mut self, area: Rect, buf: &mut Buffer) {
751 self.editor.focused = self.focus == FocusArea::Editor;
752 let pre_heights = self
753 .pre_editor
754 .iter_mut()
755 .map(|section| section.component.measure(area.width))
756 .collect::<Vec<_>>();
757 let title_height = self
758 .dialog_title
759 .as_mut()
760 .map_or(0, |title| title.measure(area.width));
761 let body_height = if self.focus == FocusArea::Selector {
762 self.selector
763 .as_mut()
764 .map_or(0, |selector| selector.measure(area.width))
765 } else {
766 self.editor.measure(area.width)
767 };
768 let middle_height = title_height.saturating_add(body_height);
769 let post_heights = self
770 .post_editor
771 .iter_mut()
772 .map(|section| section.component.measure(area.width))
773 .collect::<Vec<_>>();
774 let middle_height = middle_height.min(area.height);
775 let post_height = post_heights
776 .iter()
777 .copied()
778 .fold(0_u16, u16::saturating_add)
779 .min(area.height - middle_height);
780 let pre_height = area.height - middle_height - post_height;
781 let (pre_start, skipped_rows) = visible_suffix(&pre_heights, pre_height);
782 let bottom = area.bottom();
783 let mut y = area.y;
784
785 for (offset, section) in self.pre_editor[pre_start..].iter_mut().enumerate() {
786 let measured_height = pre_heights[pre_start + offset];
787 let skipped_rows = if offset == 0 { skipped_rows } else { 0 };
788 let height = measured_height
789 .saturating_sub(skipped_rows)
790 .min(bottom.saturating_sub(y));
791 render_bottom_clipped(
792 section.component.as_mut(),
793 Rect::new(area.x, y, area.width, height),
794 measured_height,
795 skipped_rows,
796 buf,
797 );
798 y = y.saturating_add(height);
799 }
800
801 let height = middle_height.min(bottom.saturating_sub(y));
802 self.render_middle(area, y, height, title_height, buf);
803 y = y.saturating_add(height);
804
805 for (section, measured_height) in self.post_editor.iter_mut().zip(post_heights) {
806 if y == bottom {
807 break;
808 }
809 let height = measured_height.min(bottom - y);
810 if height == 0 {
811 continue;
812 }
813 section
814 .component
815 .render(Rect::new(area.x, y, area.width, height), buf);
816 y = y.saturating_add(height);
817 }
818 self.render_overlay(area, buf);
819 }
820
821 fn handle_event(&mut self, event: &UiEvent) -> EventResult {
822 match self.focus {
823 FocusArea::Editor => self.editor.handle_event(event),
824 FocusArea::Selector => self
825 .selector
826 .as_mut()
827 .map_or(EventResult::Ignored, |selector| {
828 selector.handle_event(event)
829 }),
830 FocusArea::Overlay => self
831 .overlay
832 .as_mut()
833 .map_or(EventResult::Ignored, |overlay| overlay.handle_event(event)),
834 FocusArea::Widget => EventResult::Ignored,
835 }
836 }
837
838 fn invalidate(&mut self) {
839 for section in &mut self.pre_editor {
840 section.component.invalidate();
841 }
842 self.editor.invalidate();
843 for section in &mut self.post_editor {
844 section.component.invalidate();
845 }
846 if let Some(selector) = self.selector.as_mut() {
847 selector.invalidate();
848 }
849 if let Some(title) = self.dialog_title.as_mut() {
850 title.invalidate();
851 }
852 if let Some(overlay) = self.overlay.as_mut() {
853 overlay.invalidate();
854 }
855 }
856}
857
858#[derive(Clone, Copy, Default)]
864struct DisplayPreferences {
865 tools_expanded: bool,
867 hide_thinking: bool,
869}
870
871pub struct InteractiveRuntime<W: Write, S: SessionHost> {
888 tui: Tui<W>,
889 input: TerminalInput,
890 session: Arc<S>,
891 editor: Editor,
892 view: ViewState,
893 mapper: InputMapper,
894 input_state: InputState,
895 events: EventSubscription,
896 partial: watch::Receiver<Option<Arc<AssistantMessage>>>,
897 prompt_operations: PromptOperations,
898 startup_prompts: VecDeque<(String, Vec<ImageContent>)>,
899 coalesce_deadline: Option<Instant>,
900 pending_settle: Option<Vec<SettledBlock>>,
901 shutdown: Arc<Notify>,
902 exited: bool,
903 exit_kind: InteractiveExit,
904 last_error: Option<String>,
905 shutdown_flag: Arc<std::sync::atomic::AtomicBool>,
906 pending_ui_reinject: Vec<UiEvent>,
907 extension_runner: Option<Arc<HostExtensionRunner>>,
908 extension_events: Option<tokio::sync::broadcast::Receiver<ExtensionUiEvent>>,
909 extension_requests: Option<mpsc::Receiver<HostUiRequest>>,
910 extension_slots: std::collections::HashMap<String, ProjectedExtensionSlot>,
911 focused_extension_slot: Option<String>,
912 effective_extension_shortcuts: Vec<EffectiveExtensionShortcut>,
913 extension_action_rx: mpsc::UnboundedReceiver<Result<(), String>>,
914 extension_action_tx: mpsc::UnboundedSender<Result<(), String>>,
915 pending_extension_dialog: Option<PendingExtensionDialog>,
916 extension_select_rx: mpsc::UnboundedReceiver<String>,
917 extension_select_tx: mpsc::UnboundedSender<String>,
918 display: DisplayPreferences,
919 chat_prefix_cache: Option<Box<dyn Component>>,
920 chat_prefix_len: usize,
921 chat_tail_cache: Option<Box<dyn Component>>,
922 chat_dirty: bool,
923 active_selector: Option<Box<dyn Component>>,
925 active_selector_kind: Option<super::state::SelectorKind>,
927 submit_rx: mpsc::UnboundedReceiver<String>,
929 submit_tx: mpsc::UnboundedSender<String>,
931 select_rx: mpsc::UnboundedReceiver<(super::state::SelectorKind, String)>,
933 select_tx: mpsc::UnboundedSender<(super::state::SelectorKind, String)>,
934 cancel_rx: mpsc::UnboundedReceiver<()>,
936 cancel_tx: mpsc::UnboundedSender<()>,
937}
938
939#[derive(Clone, Copy, Debug, Eq, PartialEq)]
940enum SessionOperationKind {
941 Prompt,
942 Bash,
943}
944
945struct PromptCompletion {
947 id: u64,
948 epoch: u64,
949 kind: SessionOperationKind,
950 result: Result<(), String>,
951}
952
953struct PromptOperations {
958 epoch: u64,
959 next_id: u64,
960 tasks: JoinSet<PromptCompletion>,
961 aborts: std::collections::BTreeMap<u64, oneshot::Sender<()>>,
962 bash_operation: Option<u64>,
963}
964
965#[derive(Debug)]
966struct PendingExtensionDialog {
967 request: HostUiRequest,
968 saved_editor_text: Option<String>,
969 saved_editor_placeholder: String,
970 deadline: Option<Instant>,
971}
972
973#[derive(Clone, Debug)]
974struct EffectiveExtensionShortcut {
975 key: String,
976 dispatch_key: String,
977 parsed: ParsedKeyId,
978 description: Option<String>,
979 source: Option<String>,
980}
981
982#[derive(Clone, Debug)]
983struct ProjectedExtensionSlot {
984 placement: SlotPlacement,
985 generation: u64,
986 focusable: bool,
987}
988
989impl PromptOperations {
990 fn new() -> Self {
991 Self {
992 epoch: 0,
993 next_id: 0,
994 tasks: JoinSet::new(),
995 aborts: std::collections::BTreeMap::new(),
996 bash_operation: None,
997 }
998 }
999}
1000
1001fn initial_view(options: &InteractiveRuntimeOptions) -> ViewState {
1002 let mut view = ViewState::empty();
1003 view.theme = options.theme.clone();
1004 view.width = options.size.0;
1005 view.height = options.size.1;
1006 view.quiet = options.quiet;
1007 view.resize(options.size.0, options.size.1);
1008 if !options.migrations.migrated_auth_providers.is_empty() {
1009 view.diagnostics.entries.push(StartupDiagnostic {
1010 severity: DiagnosticSeverity::Warning,
1011 source: "migration".to_owned(),
1012 message: format!(
1013 "Migrated credentials to auth.json: {}",
1014 options.migrations.migrated_auth_providers.join(", ")
1015 ),
1016 });
1017 }
1018 view.diagnostics
1019 .entries
1020 .extend(
1021 options
1022 .migrations
1023 .deprecation_warnings
1024 .iter()
1025 .cloned()
1026 .map(|message| StartupDiagnostic {
1027 severity: DiagnosticSeverity::Warning,
1028 source: "migration".to_owned(),
1029 message,
1030 }),
1031 );
1032 view
1033}
1034
1035fn initial_prompts(options: &InteractiveRuntimeOptions) -> VecDeque<(String, Vec<ImageContent>)> {
1036 let mut prompts = VecDeque::new();
1037 if let Some(message) = options.initial_message.clone() {
1038 prompts.push_back((message, options.initial_images.clone()));
1039 }
1040 prompts.extend(
1041 options
1042 .remaining_messages
1043 .iter()
1044 .cloned()
1045 .map(|message| (message, Vec::new())),
1046 );
1047 prompts
1048}
1049
1050impl<W: Write, S: SessionHost> InteractiveRuntime<W, S> {
1051 #[must_use]
1062 #[allow(clippy::too_many_arguments)]
1063 pub fn new(
1064 mut tui: Tui<W>,
1065 input: TerminalInput,
1066 session: Arc<S>,
1067 options: &InteractiveRuntimeOptions,
1068 ) -> Self {
1069 tui.set_hardware_cursor(options.hardware_cursor);
1070 let mut view = initial_view(options);
1071 let startup_prompts = initial_prompts(options);
1072
1073 let events = session.subscribe();
1074 let partial = session.partial_rx();
1075 let snapshot = session.snapshot();
1076 project_snapshot(&mut view, &snapshot, None);
1077 view.messages = project_messages(&session.messages());
1078 let hide_thinking = session.hide_thinking_block();
1079 apply_display_preferences(&mut view.messages, false, hide_thinking);
1080 let extension_runner = session.host_extension_runner();
1081 let extension_events = extension_runner
1082 .as_ref()
1083 .map(|runner| runner.subscribe_ui());
1084 let extension_requests = extension_runner
1085 .as_ref()
1086 .and_then(|runner| runner.take_ui_requests());
1087 let initial_extension_slots = extension_runner
1088 .as_ref()
1089 .map_or_else(Vec::new, |runner| runner.current_slots());
1090 let effective_extension_shortcuts =
1091 extension_runner.as_ref().map_or_else(Vec::new, |runner| {
1092 build_effective_extension_shortcuts(&runner.raw_shortcuts())
1093 });
1094 view.extension_shortcuts = shortcut_hints(&effective_extension_shortcuts);
1095
1096 let (submit_tx, submit_rx) = mpsc::unbounded_channel::<String>();
1097 let (select_tx, select_rx) =
1098 mpsc::unbounded_channel::<(super::state::SelectorKind, String)>();
1099 let (cancel_tx, cancel_rx) = mpsc::unbounded_channel::<()>();
1100 let (extension_select_tx, extension_select_rx) = mpsc::unbounded_channel::<String>();
1101 let (extension_action_tx, extension_action_rx) = mpsc::unbounded_channel();
1102
1103 let mut editor = Editor::new(
1104 &pi_tui::components::editor::EditorTheme::default(),
1105 &EditorOptions {
1106 padding_x: 1,
1107 autocomplete_max_visible: 5,
1108 terminal_rows: options.size.1,
1109 },
1110 );
1111 let submit_tx_cb = submit_tx.clone();
1112 editor.on_submit = Some(Box::new(move |text: String| {
1113 let _ = submit_tx_cb.send(text);
1114 }));
1115
1116 let mut runtime = Self {
1117 tui,
1118 input,
1119 session,
1120 editor,
1121 view,
1122 mapper: InputMapper::new(),
1123 input_state: InputState::new(options.double_escape),
1124 events,
1125 partial,
1126 prompt_operations: PromptOperations::new(),
1127 startup_prompts,
1128 coalesce_deadline: None,
1129 pending_settle: None,
1130 shutdown: Arc::new(Notify::new()),
1131 exited: false,
1132 exit_kind: InteractiveExit::Clean,
1133 last_error: None,
1134 shutdown_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1135 pending_ui_reinject: Vec::new(),
1136 extension_runner,
1137 extension_events,
1138 extension_requests,
1139 extension_slots: std::collections::HashMap::new(),
1140 focused_extension_slot: None,
1141 effective_extension_shortcuts,
1142 extension_action_rx,
1143 extension_action_tx,
1144 pending_extension_dialog: None,
1145 extension_select_rx,
1146 extension_select_tx,
1147 display: DisplayPreferences {
1148 tools_expanded: false,
1149 hide_thinking,
1150 },
1151 chat_prefix_cache: None,
1152 chat_prefix_len: usize::MAX,
1153 chat_tail_cache: None,
1154 chat_dirty: true,
1155 active_selector: None,
1156 active_selector_kind: None,
1157 submit_rx,
1158 submit_tx,
1159 select_rx,
1160 select_tx,
1161 cancel_rx,
1162 cancel_tx,
1163 };
1164 for slot in initial_extension_slots {
1165 runtime.project_extension_slot(slot);
1166 }
1167 runtime
1168 }
1169
1170 pub fn view(&self) -> &ViewState {
1174 &self.view
1175 }
1176
1177 #[must_use]
1179 pub fn viewport_bottom_row(&self) -> u16 {
1180 self.view.height.saturating_sub(1)
1181 }
1182
1183 pub fn view_mut(&mut self) -> &mut ViewState {
1185 &mut self.view
1186 }
1187
1188 pub fn editor(&self) -> &Editor {
1190 &self.editor
1191 }
1192
1193 pub fn editor_mut(&mut self) -> &mut Editor {
1195 &mut self.editor
1196 }
1197
1198 pub fn input_state(&self) -> &InputState {
1200 &self.input_state
1201 }
1202
1203 pub fn last_error(&self) -> Option<&str> {
1205 self.last_error.as_deref()
1206 }
1207
1208 pub fn request_shutdown(&self) {
1210 self.shutdown_flag
1211 .store(true, std::sync::atomic::Ordering::SeqCst);
1212 self.shutdown.notify_one();
1213 }
1214
1215 pub fn shutdown_notify(&self) -> Arc<Notify> {
1217 Arc::clone(&self.shutdown)
1218 }
1219
1220 pub fn tui(&self) -> &Tui<W> {
1222 &self.tui
1223 }
1224
1225 pub fn tui_mut(&mut self) -> &mut Tui<W> {
1227 &mut self.tui
1228 }
1229
1230 pub fn input(&self) -> &TerminalInput {
1232 &self.input
1233 }
1234
1235 pub fn input_mut(&mut self) -> &mut TerminalInput {
1237 &mut self.input
1238 }
1239
1240 async fn initialize_run(&mut self) -> bool {
1243 self.refresh_footer().await;
1244 if let Err(error) = self.paint_frame() {
1245 self.exit_kind = InteractiveExit::IoFailure;
1246 self.last_error = Some(error.to_string());
1247 return false;
1248 }
1249 self.enqueue_next_startup_prompt().await;
1250 true
1251 }
1252
1253 fn take_latched_shutdown(&mut self) -> bool {
1258 if !self
1259 .shutdown_flag
1260 .swap(false, std::sync::atomic::Ordering::SeqCst)
1261 {
1262 return false;
1263 }
1264 if !self.exited {
1265 self.exit_kind = InteractiveExit::Clean;
1266 self.exited = true;
1267 }
1268 true
1269 }
1270
1271 async fn reinject_pending_ui_event(&mut self) -> Option<bool> {
1272 let event = self.pending_ui_reinject.pop()?;
1273 if let Err(err) = self.handle_ui_event(event).await {
1274 self.fail_io(&err);
1275 return Some(false);
1276 }
1277 Some(self.settle_pending())
1278 }
1279
1280 pub async fn run(&mut self) -> io::Result<InteractiveExit> {
1289 if !self.initialize_run().await {
1290 return Ok(self.exit_kind);
1291 }
1292
1293 while !self.exited {
1294 if self.take_latched_shutdown() {
1295 break;
1296 }
1297
1298 if let Some(keep_running) = self.reinject_pending_ui_event().await {
1301 if !keep_running {
1302 break;
1303 }
1304 continue;
1305 }
1306
1307 let now = Instant::now();
1308 let coalesce_wait = self
1309 .coalesce_deadline
1310 .map_or(Duration::from_hours(1), |deadline| {
1311 deadline.saturating_duration_since(now)
1312 });
1313
1314 tokio::select! {
1315 biased;
1316
1317 () = self.shutdown.notified() => {
1318 self.exit_kind = InteractiveExit::Clean;
1319 self.exited = true;
1320 }
1321 ui = self.input.recv() => {
1322 if let Some(event) = ui {
1323 if let Err(err) = self.handle_ui_event(event).await {
1324 self.fail_io(&err);
1325 }
1326 } else {
1327 self.exit_kind = InteractiveExit::Clean;
1329 self.exited = true;
1330 }
1331 }
1332 ev = self.events.rx.recv() => {
1333 if let Some(event) = ev {
1334 self.handle_session_event(&event);
1335 if event_refreshes_footer(&event) {
1336 self.refresh_footer().await;
1337 }
1338 } else {
1339 self.exit_kind = InteractiveExit::SessionEnded;
1340 self.exited = true;
1341 }
1342 }
1343 extension_event = recv_extension_event(&mut self.extension_events) => {
1344 if let Some(extension_event) = extension_event {
1345 self.handle_extension_event(extension_event);
1346 } else {
1347 self.extension_events = None;
1348 }
1349 }
1350 extension_request = recv_extension_request(&mut self.extension_requests) => {
1351 if let Some(extension_request) = extension_request {
1352 self.begin_extension_dialog(extension_request).await;
1353 } else {
1354 self.extension_requests = None;
1355 }
1356 }
1357 () = wait_extension_deadline(
1358 self.pending_extension_dialog.as_ref().and_then(|dialog| dialog.deadline),
1359 ), if self.pending_extension_dialog.as_ref().and_then(|dialog| dialog.deadline).is_some() => {
1360 self.cancel_extension_dialog().await;
1361 }
1362 changed = self.partial.changed() => {
1363 if changed.is_ok() {
1364 self.handle_partial_update();
1365 }
1366 }
1367 completion = self.prompt_operations.tasks.join_next(), if !self.prompt_operations.tasks.is_empty() => {
1368 if let Some(completion) = completion {
1369 if self.handle_prompt_completion(completion) {
1370 self.refresh_footer().await;
1371 }
1372 if self.prompt_operations.tasks.is_empty() {
1373 self.enqueue_next_startup_prompt().await;
1374 }
1375 }
1376 }
1377 () = tokio::time::sleep(coalesce_wait) => {
1378 if self.coalesce_deadline.is_some() {
1379 self.coalesce_deadline = None;
1380 if let Err(err) = self.paint_frame() {
1381 self.fail_io(&err);
1382 }
1383 }
1384 }
1385 extension_result = self.extension_action_rx.recv() => {
1386 if let Some(Err(error)) = extension_result {
1387 self.record_error(error);
1388 }
1389 }
1390 }
1391
1392 self.settle_pending();
1394 }
1395
1396 Ok(self.finish_run().await)
1397 }
1398
1399 fn fail_io(&mut self, err: &io::Error) {
1401 self.exit_kind = InteractiveExit::IoFailure;
1402 self.last_error = Some(err.to_string());
1403 self.exited = true;
1404 }
1405
1406 fn settle_pending(&mut self) -> bool {
1408 if let Some(blocks) = self.pending_settle.take()
1409 && let Err(err) = self.commit_settle(blocks)
1410 {
1411 self.fail_io(&err);
1412 return false;
1413 }
1414 true
1415 }
1416
1417 async fn finish_run(&mut self) -> InteractiveExit {
1418 self.quiesce_prompt_operations().await;
1421
1422 if matches!(
1424 self.exit_kind,
1425 InteractiveExit::Clean | InteractiveExit::SessionEnded
1426 ) {
1427 let _ = self.paint_frame();
1428 }
1429 self.exit_kind
1430 }
1431
1432 async fn handle_ui_event(&mut self, event: UiEvent) -> io::Result<()> {
1437 let Some(event) = self.intercept_terminal_input(event).await else {
1438 return Ok(());
1439 };
1440 if self.route_extension_input(&event) {
1441 return Ok(());
1442 }
1443 let released_extension_focus = matches!(
1444 &event,
1445 UiEvent::Key(key)
1446 if key.code == KeyCode::Esc
1447 && key.kind != crossterm::event::KeyEventKind::Release
1448 ) && self.release_extension_focus();
1449 if released_extension_focus {
1453 self.paint_frame()?;
1454 return Ok(());
1455 }
1456 let app_preempts_focus = is_global_app_binding(&event);
1459 let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
1460 let saved_selector = self.active_selector.take();
1461 let mut root = self.build_root(saved_editor, saved_selector);
1462 let editor_result = if app_preempts_focus {
1463 EventResult::Ignored
1464 } else {
1465 root.handle_event(&event)
1466 };
1467 self.recover_root(root);
1468 self.ensure_editor_on_submit();
1471
1472 let live_text = self.editor.get_text();
1475 self.view.editor.text.clone_from(&live_text);
1476 let (_line, col) = self.editor.get_cursor();
1477 self.view.editor.cursor = col;
1478
1479 let mut actions: Vec<ViewAction> = Vec::new();
1481 while let Ok(text) = self.submit_rx.try_recv() {
1482 actions.push(ViewAction::Submit { text });
1483 if self.pending_extension_dialog.is_none() {
1484 actions.push(ViewAction::ClearEditor);
1485 }
1486 }
1487 while let Ok((selector, value)) = self.select_rx.try_recv() {
1488 actions.push(ViewAction::SelectConfirmed { selector, value });
1489 }
1490 while let Ok(value) = self.extension_select_rx.try_recv() {
1491 self.finish_extension_selection(value).await;
1492 }
1493 while self.cancel_rx.try_recv().is_ok() {
1494 if self.pending_extension_dialog.is_some() {
1495 self.cancel_extension_dialog().await;
1496 } else {
1497 actions.push(ViewAction::SelectCancelled);
1498 }
1499 }
1500
1501 actions.extend(self.mapper.map(
1504 &event,
1505 &self.view,
1506 &live_text,
1507 &mut self.input_state,
1508 editor_result.is_handled(),
1509 ));
1510
1511 let mut needs_immediate_repaint = editor_result.needs_render();
1512 for action in actions {
1513 let outcome = self.dispatch_action(action).await;
1514 if matches!(outcome, ActionOutcome::Repaint) {
1515 needs_immediate_repaint = true;
1516 }
1517 if matches!(outcome, ActionOutcome::Exit) {
1518 self.exited = true;
1519 self.exit_kind = InteractiveExit::Clean;
1520 }
1521 if matches!(outcome, ActionOutcome::Suspend) {
1522 self.exited = true;
1523 self.exit_kind = InteractiveExit::Suspend;
1524 }
1525 if matches!(outcome, ActionOutcome::ExternalEditor) {
1526 self.exited = true;
1527 self.exit_kind = InteractiveExit::ExternalEditor;
1528 }
1529 }
1530
1531 if needs_immediate_repaint {
1532 self.paint_frame()?;
1534 }
1535 Ok(())
1536 }
1537
1538 fn handle_session_event(&mut self, event: &AgentSessionEvent) {
1539 project_event(&mut self.view, event);
1540 apply_display_preferences(
1541 &mut self.view.messages,
1542 self.display.tools_expanded,
1543 self.display.hide_thinking,
1544 );
1545 if matches!(event, AgentSessionEvent::MessageUpdate { .. }) {
1546 self.chat_dirty = true;
1547 } else {
1548 self.chat_prefix_cache = None;
1549 self.chat_prefix_len = usize::MAX;
1550 self.chat_dirty = true;
1551 }
1552 self.arm_coalescer();
1553 }
1554
1555 fn handle_partial_update(&mut self) {
1556 let partial = self.partial.borrow_and_update().clone();
1557 if let Some(message) = partial {
1558 let mut found = false;
1560 for item in &mut self.view.messages {
1561 if let MessageView::Assistant(view) = item
1562 && view.streaming
1563 {
1564 view.message = (*message).clone();
1565 found = true;
1566 break;
1567 }
1568 }
1569 if !found {
1570 self.view
1571 .messages
1572 .push(MessageView::streaming_assistant((*message).clone()));
1573 }
1574 self.view.streaming = true;
1575 apply_display_preferences(
1576 &mut self.view.messages,
1577 self.display.tools_expanded,
1578 self.display.hide_thinking,
1579 );
1580 self.chat_dirty = true;
1581 self.arm_coalescer();
1582 } else {
1583 self.arm_coalescer();
1585 }
1586 }
1587
1588 fn toggle_thinking(&mut self) -> ActionOutcome {
1594 self.display.hide_thinking = !self.display.hide_thinking;
1595 if let Err(error) = self
1596 .session
1597 .set_hide_thinking_block(self.display.hide_thinking)
1598 {
1599 self.last_error = Some(error);
1600 }
1601 self.reapply_display_preferences()
1602 }
1603
1604 fn toggle_tool_expand(&mut self) -> ActionOutcome {
1606 self.display.tools_expanded = !self.display.tools_expanded;
1607 self.reapply_display_preferences()
1608 }
1609
1610 fn reapply_display_preferences(&mut self) -> ActionOutcome {
1611 apply_display_preferences(
1612 &mut self.view.messages,
1613 self.display.tools_expanded,
1614 self.display.hide_thinking,
1615 );
1616 self.chat_dirty = true;
1617 ActionOutcome::Repaint
1618 }
1619
1620 async fn dispatch_action(&mut self, action: ViewAction) -> ActionOutcome {
1621 match action {
1622 ViewAction::None | ViewAction::Consumed => ActionOutcome::None,
1623 ViewAction::ExternalEditor => ActionOutcome::ExternalEditor,
1624 ViewAction::Render | ViewAction::OpenSettingsSubmenu { .. } => ActionOutcome::Repaint,
1625 ViewAction::ToggleThinking => self.toggle_thinking(),
1626 ViewAction::ToggleToolExpand => self.toggle_tool_expand(),
1627 ViewAction::Submit { text } => self.submit_text(text, false).await,
1628 ViewAction::SubmitBash {
1629 command,
1630 exclude_from_context,
1631 } => self.dispatch_bash(&command, exclude_from_context).await,
1632 ViewAction::Interrupt => self.dispatch_interrupt().await,
1633 ViewAction::ClearEditor => self.clear_editor(),
1634 ViewAction::Exit => ActionOutcome::Exit,
1635 ViewAction::Suspend => ActionOutcome::Suspend,
1636 ViewAction::CycleThinking { .. } => {
1637 self.record_err(self.session.cycle_thinking_level().await);
1638 self.refresh_footer().await;
1639 ActionOutcome::Repaint
1640 }
1641 ViewAction::CycleModel { forward } => {
1642 self.record_err(self.session.cycle_model(forward).await);
1643 self.refresh_footer().await;
1644 ActionOutcome::Repaint
1645 }
1646 ViewAction::OpenModelSelector => {
1647 self.open_selector(super::state::SelectorKind::Model).await
1648 }
1649 ViewAction::OpenSettings => {
1650 self.open_selector(super::state::SelectorKind::Settings)
1651 .await
1652 }
1653 ViewAction::OpenSessionPicker => {
1654 self.open_selector(super::state::SelectorKind::Session)
1655 .await
1656 }
1657 ViewAction::OpenTreeSelector => {
1658 self.open_selector(super::state::SelectorKind::Tree).await
1659 }
1660 ViewAction::OpenForkSelector => {
1661 self.open_selector(super::state::SelectorKind::Fork).await
1662 }
1663 ViewAction::OpenTrustSelector => {
1664 self.open_selector(super::state::SelectorKind::Trust).await
1665 }
1666 ViewAction::OpenLogin { .. } => self.open_overlay(OverlayKind::Login),
1667 ViewAction::Logout => {
1668 self.record_err(self.session.logout().await);
1669 ActionOutcome::None
1670 }
1671 ViewAction::OpenScopedModels => {
1672 self.open_selector(super::state::SelectorKind::ScopedModels)
1673 .await
1674 }
1675 ViewAction::OpenConfigSelector => {
1676 self.open_selector(super::state::SelectorKind::Config).await
1677 }
1678 ViewAction::ToggleShortcutHelp => self.toggle_shortcut_help(),
1679 ViewAction::ShowChangelog => self.open_overlay(OverlayKind::Changelog),
1680 ViewAction::Paste { text } => self.paste_text(&text),
1681 ViewAction::QueueFollowUp { text } => self.queue_follow_up(text).await,
1682 ViewAction::DequeueFollowUp => self.dequeue_follow_up(),
1683 ViewAction::CopyLastAssistant => self.copy_last_assistant().await,
1684 ViewAction::Reload => {
1685 if self.pending_extension_dialog.is_some() {
1686 self.cancel_extension_dialog().await;
1687 }
1688 self.record_err(self.session.reload().await);
1689 self.rebind_extension_channels().await;
1690 ActionOutcome::Repaint
1691 }
1692 ViewAction::SlashCommand { name, args } => self.submit_slash_command(name, args).await,
1693 ViewAction::SelectConfirmed { selector, value } => {
1694 self.handle_select_confirmed(selector, value).await
1695 }
1696 ViewAction::SelectCancelled => {
1697 self.close_selector();
1698 ActionOutcome::Repaint
1699 }
1700 ViewAction::FocusChanged { area } => {
1701 self.view.focus = area;
1702 ActionOutcome::Repaint
1703 }
1704 ViewAction::ShowOverlay { kind } => self.open_overlay(kind),
1705 ViewAction::DismissOverlay => self.dismiss_overlay(),
1706 ViewAction::NewSession => self.replace_session(SessionReplacement::New).await,
1707 ViewAction::Fork => self.replace_session(SessionReplacement::Fork).await,
1708 ViewAction::Clone => self.replace_session(SessionReplacement::Clone).await,
1709 ViewAction::Compact { instructions } => {
1710 self.record_err(self.session.compact(instructions.as_deref()).await);
1711 ActionOutcome::None
1712 }
1713 ViewAction::Resize { width, height } => self.handle_resize(width, height),
1714 }
1715 }
1716
1717 async fn submit_slash_command(&mut self, name: String, args: String) -> ActionOutcome {
1718 if let Some(runner) = self.extension_runner.as_ref()
1719 && runner
1720 .registry()
1721 .commands()
1722 .iter()
1723 .any(|command| command.name == name)
1724 {
1725 let runner = Arc::clone(runner);
1726 let result_tx = self.extension_action_tx.clone();
1727 tokio::spawn(async move {
1728 let result = runner
1729 .execute_command(&name, &args)
1730 .await
1731 .map(|_| ())
1732 .map_err(|error| error.to_string());
1733 let _ = result_tx.send(result);
1734 });
1735 return ActionOutcome::Repaint;
1736 }
1737
1738 let command = if args.is_empty() {
1739 format!("/{name}")
1740 } else {
1741 format!("/{name} {args}")
1742 };
1743 self.submit_text(command, false).await
1744 }
1745
1746 async fn dispatch_bash(&mut self, command: &str, exclude_from_context: bool) -> ActionOutcome {
1747 if !self
1748 .enqueue_bash(command.to_owned(), exclude_from_context)
1749 .await
1750 {
1751 self.last_error = Some("a bash command is already running".to_owned());
1752 return ActionOutcome::Repaint;
1753 }
1754 self.view.editor.border = EditorBorder::Bash;
1755 ActionOutcome::Repaint
1756 }
1757
1758 async fn dispatch_interrupt(&mut self) -> ActionOutcome {
1759 self.set_status(SessionStatus {
1760 kind: StatusKind::Working,
1761 frame: 0,
1762 message: "Aborting…".to_owned(),
1763 });
1764 self.record_err(self.session.abort().await);
1765 self.refresh_footer().await;
1766 ActionOutcome::Repaint
1767 }
1768
1769 fn clear_editor(&mut self) -> ActionOutcome {
1770 self.editor.set_text("");
1771 self.view.editor.text.clear();
1772 self.view.editor.cursor = 0;
1773 ActionOutcome::Repaint
1774 }
1775
1776 fn toggle_shortcut_help(&mut self) -> ActionOutcome {
1777 if self.view.overlay.is_some() {
1778 self.view.overlay = None;
1779 self.view.focus = FocusArea::Editor;
1780 } else {
1781 self.view.overlay = Some(Overlay {
1782 kind: OverlayKind::ShortcutHelp,
1783 lines: Vec::new(),
1784 height: 1,
1785 });
1786 self.view.extension_overlay_slot = None;
1787 self.view.focus = FocusArea::Overlay;
1788 }
1789 self.view.extension_overlay_slot = None;
1790 self.input_state.reset_taps();
1791 ActionOutcome::Repaint
1792 }
1793
1794 fn paste_text(&mut self, text: &str) -> ActionOutcome {
1795 if text.is_empty() {
1796 return ActionOutcome::None;
1797 }
1798 self.editor.insert_text_at_cursor(text);
1799 self.view.editor.text = self.editor.get_text();
1800 ActionOutcome::Repaint
1801 }
1802
1803 async fn queue_follow_up(&mut self, text: String) -> ActionOutcome {
1804 self.record_err(self.session.follow_up(&text).await);
1805 self.view.pending.follow_up.push(PendingMessage {
1806 kind: PendingKind::FollowUp,
1807 text,
1808 });
1809 ActionOutcome::Repaint
1810 }
1811
1812 fn dequeue_follow_up(&mut self) -> ActionOutcome {
1813 let Some(message) = self.view.pending.follow_up.pop() else {
1814 return ActionOutcome::None;
1815 };
1816 self.editor.set_text(&message.text);
1817 self.view.editor.text = self.editor.get_text();
1818 ActionOutcome::Repaint
1819 }
1820
1821 async fn copy_last_assistant(&mut self) -> ActionOutcome {
1822 match self.session.last_assistant_text().await {
1823 Ok(Some(text)) if !text.is_empty() => {
1824 if crate::core::platform::clipboard::copy_to_clipboard(&text).is_ok() {
1825 self.set_status(SessionStatus {
1826 kind: StatusKind::Working,
1827 frame: 0,
1828 message: "Copied last assistant message".to_owned(),
1829 });
1830 } else {
1831 self.last_error = Some("Failed to copy to clipboard".to_owned());
1832 }
1833 }
1834 Ok(_) => self.set_status(SessionStatus {
1835 kind: StatusKind::Working,
1836 frame: 0,
1837 message: "No assistant text to copy".to_owned(),
1838 }),
1839 Err(error) => self.last_error = Some(error),
1840 }
1841 ActionOutcome::Repaint
1842 }
1843
1844 fn release_extension_focus(&mut self) -> bool {
1845 let Some(key) = self.focused_extension_slot.take() else {
1846 return false;
1847 };
1848 for widget in self
1849 .view
1850 .widgets_above
1851 .iter_mut()
1852 .chain(self.view.widgets_below.iter_mut())
1853 {
1854 widget.focused = false;
1855 }
1856 if self
1857 .extension_slots
1858 .get(&key)
1859 .is_some_and(|slot| slot.placement == SlotPlacement::Overlay)
1860 {
1861 self.view.overlay = None;
1862 self.view.extension_overlay_slot = None;
1863 }
1864 self.view.focus = FocusArea::Editor;
1865 self.input_state.reset_taps();
1866 true
1867 }
1868
1869 fn dismiss_overlay(&mut self) -> ActionOutcome {
1870 self.release_extension_focus();
1871 self.view.overlay = None;
1872 self.view.extension_overlay_slot = None;
1873 self.view.focus = FocusArea::Editor;
1874 self.input_state.reset_taps();
1875 ActionOutcome::Repaint
1876 }
1877
1878 async fn replace_session(&mut self, replacement: SessionReplacement) -> ActionOutcome {
1879 self.quiesce_prompt_operations().await;
1880 if self.pending_extension_dialog.is_some() {
1881 self.cancel_extension_dialog().await;
1882 }
1883 let result = match replacement {
1884 SessionReplacement::New => self.session.new_session().await,
1885 SessionReplacement::Fork => self.session.fork("").await,
1886 SessionReplacement::Clone => <S as SessionHost>::clone(&self.session).await,
1887 };
1888 self.record_err(result);
1889 self.rebind_session_channels().await;
1890 self.refresh_footer().await;
1891 ActionOutcome::Repaint
1892 }
1893
1894 async fn submit_text(&mut self, text: String, force_follow_up: bool) -> ActionOutcome {
1895 if let Some(dialog) = self.pending_extension_dialog.as_ref() {
1896 match &dialog.request {
1897 HostUiRequest::Input { id, .. } => {
1898 let response = HostUiResponse::Input {
1899 id: *id,
1900 value: Some(text),
1901 };
1902 self.finish_extension_dialog(response).await;
1903 return ActionOutcome::Repaint;
1904 }
1905 HostUiRequest::Editor { id, .. } => {
1906 let response = HostUiResponse::Editor {
1907 id: *id,
1908 value: Some(text),
1909 };
1910 self.finish_extension_dialog(response).await;
1911 return ActionOutcome::Repaint;
1912 }
1913 HostUiRequest::Select { .. } | HostUiRequest::Confirm { .. } => {}
1914 }
1915 }
1916 let trimmed = text.trim().to_owned();
1917 if trimmed.is_empty() {
1918 return ActionOutcome::None;
1919 }
1920 if trimmed == "/quit" {
1921 return ActionOutcome::Exit;
1922 }
1923 if let Some(command) = parse_typed_builtin(&trimmed) {
1924 return match command {
1925 TypedBuiltin::Compact(instructions) => {
1926 self.record_err(self.session.compact(instructions).await);
1927 ActionOutcome::None
1928 }
1929 TypedBuiltin::Fork => self.open_selector(super::state::SelectorKind::Fork).await,
1930 TypedBuiltin::Resume => {
1931 self.open_selector(super::state::SelectorKind::Session)
1932 .await
1933 }
1934 TypedBuiltin::Reload => {
1935 self.record_err(self.session.reload().await);
1936 ActionOutcome::Repaint
1937 }
1938 };
1939 }
1940
1941 if let Some(stripped) = trimmed.strip_prefix("!!") {
1943 let cmd = stripped.trim().to_owned();
1944 if !cmd.is_empty() {
1945 return self.dispatch_bash(&cmd, true).await;
1946 }
1947 } else if let Some(stripped) = trimmed.strip_prefix('!') {
1948 let cmd = stripped.trim().to_owned();
1949 if !cmd.is_empty() {
1950 return self.dispatch_bash(&cmd, false).await;
1951 }
1952 }
1953
1954 let is_slash = trimmed.starts_with('/');
1955 let snapshot = self.session.snapshot();
1956 let opts = if snapshot.is_admission_active() && !is_slash {
1959 PromptOptions {
1960 streaming_behavior: Some(if force_follow_up {
1961 StreamingBehavior::FollowUp
1962 } else {
1963 StreamingBehavior::Steer
1964 }),
1965 ..PromptOptions::default()
1966 }
1967 } else if force_follow_up {
1968 PromptOptions {
1969 streaming_behavior: Some(StreamingBehavior::FollowUp),
1970 ..PromptOptions::default()
1971 }
1972 } else {
1973 PromptOptions::default()
1974 };
1975 self.enqueue_prompt(trimmed, opts).await;
1976 ActionOutcome::None
1977 }
1978
1979 async fn enqueue_prompt(&mut self, text: String, opts: PromptOptions) {
1985 let id = self.prompt_operations.next_id;
1986 self.prompt_operations.next_id = id.wrapping_add(1);
1987 let epoch = self.prompt_operations.epoch;
1988 let session = Arc::clone(&self.session);
1989 let abort = self.session.abort();
1990 let (abort_tx, mut abort_rx) = oneshot::channel();
1991 let (admitted_tx, admitted_rx) = oneshot::channel();
1992
1993 self.prompt_operations.tasks.spawn(async move {
1994 let mut prompt = session.prompt(&text, opts);
1995 let first_poll = poll_fn(|cx| {
1996 Poll::Ready(match prompt.as_mut().poll(cx) {
1997 Poll::Ready(result) => Some(result),
1998 Poll::Pending => None,
1999 })
2000 })
2001 .await;
2002 let _ = admitted_tx.send(());
2003
2004 let result = if let Some(result) = first_poll {
2005 result
2006 } else {
2007 tokio::select! {
2008 result = &mut prompt => result,
2009 _ = &mut abort_rx => {
2010 let abort_result = abort.await;
2011 let prompt_result = prompt.await;
2012 prompt_result.and(abort_result)
2013 }
2014 }
2015 };
2016 PromptCompletion {
2017 id,
2018 epoch,
2019 kind: SessionOperationKind::Prompt,
2020 result,
2021 }
2022 });
2023 self.prompt_operations.aborts.insert(id, abort_tx);
2024
2025 let _ = admitted_rx.await;
2028 }
2029
2030 async fn enqueue_next_startup_prompt(&mut self) {
2031 let Some((text, images)) = self.startup_prompts.pop_front() else {
2032 return;
2033 };
2034 self.enqueue_prompt(
2035 text,
2036 PromptOptions {
2037 images,
2038 source: Some("cli".to_owned()),
2039 ..PromptOptions::default()
2040 },
2041 )
2042 .await;
2043 }
2044
2045 async fn enqueue_bash(&mut self, command: String, exclude_from_context: bool) -> bool {
2046 if self.prompt_operations.bash_operation.is_some() {
2047 return false;
2048 }
2049 let id = self.prompt_operations.next_id;
2050 self.prompt_operations.next_id = id.wrapping_add(1);
2051 let epoch = self.prompt_operations.epoch;
2052 let session = Arc::clone(&self.session);
2053 let abort = self.session.abort();
2054 let (abort_tx, mut abort_rx) = oneshot::channel();
2055 let (admitted_tx, admitted_rx) = oneshot::channel();
2056
2057 self.prompt_operations.tasks.spawn(async move {
2058 let mut execution = session.execute_bash(&command, exclude_from_context);
2059 let first_poll = poll_fn(|cx| {
2060 Poll::Ready(match execution.as_mut().poll(cx) {
2061 Poll::Ready(result) => Some(result),
2062 Poll::Pending => None,
2063 })
2064 })
2065 .await;
2066 let _ = admitted_tx.send(());
2067 let result = if let Some(result) = first_poll {
2068 result
2069 } else {
2070 tokio::select! {
2071 result = &mut execution => result,
2072 _ = &mut abort_rx => {
2073 let abort_result = abort.await;
2074 let execution_result = execution.await;
2075 execution_result.and(abort_result)
2076 }
2077 }
2078 };
2079 PromptCompletion {
2080 id,
2081 epoch,
2082 kind: SessionOperationKind::Bash,
2083 result,
2084 }
2085 });
2086 self.prompt_operations.aborts.insert(id, abort_tx);
2087 self.prompt_operations.bash_operation = Some(id);
2088 let _ = admitted_rx.await;
2089 true
2090 }
2091
2092 fn handle_prompt_completion(
2093 &mut self,
2094 completion: Result<PromptCompletion, JoinError>,
2095 ) -> bool {
2096 match completion {
2097 Ok(completion) => {
2098 self.prompt_operations.aborts.remove(&completion.id);
2099 if completion.kind == SessionOperationKind::Bash {
2100 self.prompt_operations.bash_operation = None;
2101 }
2102 if completion.epoch != self.prompt_operations.epoch {
2103 return false;
2104 }
2105 let refresh_footer = completion.kind == SessionOperationKind::Bash;
2106 self.record_err(completion.result);
2107 refresh_footer
2108 }
2109 Err(error) => {
2110 self.prompt_operations
2111 .aborts
2112 .retain(|_, abort| !abort.is_closed());
2113 if self
2114 .prompt_operations
2115 .bash_operation
2116 .is_some_and(|id| !self.prompt_operations.aborts.contains_key(&id))
2117 {
2118 self.prompt_operations.bash_operation = None;
2119 }
2120 if !error.is_cancelled() {
2121 self.record_err(Err(format!("session operation failed: {error}")));
2122 }
2123 false
2124 }
2125 }
2126 }
2127
2128 async fn quiesce_prompt_operations(&mut self) {
2131 self.prompt_operations.epoch = self.prompt_operations.epoch.wrapping_add(1);
2132 for (_, abort) in std::mem::take(&mut self.prompt_operations.aborts) {
2133 let _ = abort.send(());
2134 }
2135 self.prompt_operations.bash_operation = None;
2136 while self.prompt_operations.tasks.join_next().await.is_some() {}
2137 }
2138
2139 async fn handle_select_confirmed(
2140 &mut self,
2141 selector: super::state::SelectorKind,
2142 value: String,
2143 ) -> ActionOutcome {
2144 match selector {
2145 super::state::SelectorKind::Model
2146 | super::state::SelectorKind::Tree
2147 | super::state::SelectorKind::Trust
2148 | super::state::SelectorKind::Settings
2149 | super::state::SelectorKind::Config
2150 | super::state::SelectorKind::ScopedModels
2151 | super::state::SelectorKind::Auth => {
2152 self.close_selector();
2153 ActionOutcome::Repaint
2154 }
2155 super::state::SelectorKind::Session => {
2156 self.quiesce_prompt_operations().await;
2157 if self.pending_extension_dialog.is_some() {
2158 self.cancel_extension_dialog().await;
2159 }
2160 self.record_err(self.session.switch_session(&value).await);
2161 self.rebind_session_channels().await;
2162 self.refresh_footer().await;
2163 self.close_selector();
2164 ActionOutcome::Repaint
2165 }
2166 super::state::SelectorKind::Fork => {
2167 self.quiesce_prompt_operations().await;
2168 if self.pending_extension_dialog.is_some() {
2169 self.cancel_extension_dialog().await;
2170 }
2171 self.record_err(self.session.fork(&value).await);
2172 self.rebind_session_channels().await;
2173 self.refresh_footer().await;
2174 self.close_selector();
2175 ActionOutcome::Repaint
2176 }
2177 }
2178 }
2179
2180 fn close_selector(&mut self) {
2181 self.view.overlay = None;
2182 self.view.extension_overlay_slot = None;
2183 self.active_selector = None;
2184 self.active_selector_kind = None;
2185 self.view.focus = FocusArea::Editor;
2186 self.input_state.reset_taps();
2187 }
2188
2189 fn handle_resize(&mut self, width: u16, height: u16) -> ActionOutcome {
2193 self.tui.note_resize(width, height);
2194 self.view.resize(width, height);
2195
2196 let mut preserved: Vec<UiEvent> = Vec::new();
2201 while let Ok(next) = self.input.receiver_mut().try_recv() {
2202 match next {
2203 UiEvent::Resize { width, height } => {
2204 self.tui.note_resize(width, height);
2205 self.view.resize(width, height);
2206 }
2207 other => preserved.push(other),
2208 }
2209 }
2210 for event in preserved.into_iter().rev() {
2211 self.pending_ui_reinject.push(event);
2212 }
2213
2214 let result = self.commit_reanchor();
2215 if result.is_err() {
2216 self.exited = true;
2217 self.exit_kind = InteractiveExit::IoFailure;
2218 }
2219 ActionOutcome::Repaint
2220 }
2221
2222 fn open_overlay(&mut self, kind: OverlayKind) -> ActionOutcome {
2223 self.view.overlay = Some(Overlay {
2224 kind,
2225 lines: Vec::new(),
2226 height: 1,
2227 });
2228 self.view.extension_overlay_slot = None;
2229 self.view.focus = FocusArea::Overlay;
2230 self.input_state.reset_taps();
2231 ActionOutcome::Repaint
2232 }
2233
2234 async fn open_selector(&mut self, kind: super::state::SelectorKind) -> ActionOutcome {
2235 match self.load_selector_component(kind).await {
2236 Ok(component) => {
2237 self.active_selector = Some(component);
2238 self.active_selector_kind = Some(kind);
2239 self.view.focus = FocusArea::Selector;
2240 self.view.overlay = None;
2241 self.view.extension_overlay_slot = None;
2242 self.input_state.reset_taps();
2243 }
2244 Err(error) => self.last_error = Some(error),
2245 }
2246 ActionOutcome::Repaint
2247 }
2248
2249 async fn load_selector_component(
2250 &mut self,
2251 kind: super::state::SelectorKind,
2252 ) -> Result<Box<dyn Component>, String> {
2253 use pi_tui::components::SelectItem;
2254
2255 match kind {
2256 super::state::SelectorKind::Model => {
2257 let entries = self.session.get_model_entries().await?;
2258 let items = entries
2259 .into_iter()
2260 .map(|entry| {
2261 SelectItem::new(entry.value, entry.label)
2262 .with_description(entry.description.unwrap_or_default())
2263 })
2264 .collect();
2265 Ok(self.build_select_list(kind, items))
2266 }
2267 super::state::SelectorKind::Session => {
2268 let entries = self.session.get_session_entries().await?;
2269 let items = entries
2270 .into_iter()
2271 .map(|entry| {
2272 SelectItem::new(entry.value, entry.label)
2273 .with_description(entry.description.unwrap_or_default())
2274 })
2275 .collect();
2276 Ok(self.build_select_list(kind, items))
2277 }
2278 super::state::SelectorKind::Tree => {
2279 let entries = self.session.get_tree_entries().await?;
2280 Ok(self.build_tree_select_list(kind, entries))
2281 }
2282 super::state::SelectorKind::Fork => {
2283 let entries = self.session.get_fork_entries().await?;
2284 Ok(self.build_tree_select_list(kind, entries))
2285 }
2286 super::state::SelectorKind::Auth => {
2287 let entries = self.session.get_auth_entries().await?;
2288 let items = entries
2289 .into_iter()
2290 .map(|entry| {
2291 SelectItem::new(entry.value, entry.label)
2292 .with_description(entry.description.unwrap_or_default())
2293 })
2294 .collect();
2295 Ok(self.build_select_list(kind, items))
2296 }
2297 super::state::SelectorKind::ScopedModels => {
2298 let (entries, enabled) = self.session.get_scoped_models_entries().await?;
2299 let items = entries
2300 .into_iter()
2301 .map(|entry| {
2302 let mark = if enabled.get(&entry.value).copied().unwrap_or(false) {
2303 "[x]"
2304 } else {
2305 "[ ]"
2306 };
2307 SelectItem::new(entry.value, format!("{mark} {}", entry.label))
2308 .with_description(entry.description.unwrap_or_default())
2309 })
2310 .collect();
2311 Ok(self.build_select_list(kind, items))
2312 }
2313 super::state::SelectorKind::Trust => {
2314 let rows = self.session.get_trust_entries().await?;
2315 Ok(self.build_settings_list(kind, rows))
2316 }
2317 super::state::SelectorKind::Settings => {
2318 let rows = self.session.get_settings_entries().await?;
2319 Ok(self.build_settings_list(kind, rows))
2320 }
2321 super::state::SelectorKind::Config => {
2322 let rows = self.session.get_config_entries().await?;
2323 Ok(self.build_settings_list(kind, rows))
2324 }
2325 }
2326 }
2327
2328 fn build_select_list(
2329 &self,
2330 kind: super::state::SelectorKind,
2331 items: Vec<pi_tui::components::SelectItem>,
2332 ) -> Box<dyn Component> {
2333 let mut list = pi_tui::components::SelectList::new(
2334 items,
2335 super::selectors::SELECTOR_MAX_VISIBLE,
2336 super::theme::select_list_theme(),
2337 );
2338 list.set_selected_index(0);
2339 let select_tx = self.select_tx.clone();
2340 list.on_select = Some(Box::new(move |item| {
2341 let _ = select_tx.send((kind, item.value.clone()));
2342 }));
2343 let cancel_tx = self.cancel_tx.clone();
2344 list.on_cancel = Some(Box::new(move || {
2345 let _ = cancel_tx.send(());
2346 }));
2347 Box::new(list)
2348 }
2349
2350 fn build_tree_select_list(
2351 &self,
2352 kind: super::state::SelectorKind,
2353 entries: Vec<super::state::TreeEntry>,
2354 ) -> Box<dyn Component> {
2355 let items = entries
2356 .into_iter()
2357 .map(|entry| {
2358 let label = format!("{}{}", " ".repeat(entry.depth), entry.label);
2359 pi_tui::components::SelectItem::new(entry.value, label)
2360 })
2361 .collect();
2362 self.build_select_list(kind, items)
2363 }
2364
2365 fn build_settings_list(
2366 &self,
2367 kind: super::state::SelectorKind,
2368 rows: Vec<super::state::SettingsRow>,
2369 ) -> Box<dyn Component> {
2370 let items = rows
2371 .into_iter()
2372 .map(|row| {
2373 pi_tui::components::SelectItem::new(
2374 row.id,
2375 format!("{} {}", row.label, row.current_value),
2376 )
2377 .with_description(row.description.unwrap_or_default())
2378 })
2379 .collect();
2380 self.build_select_list(kind, items)
2381 }
2382
2383 fn build_extension_select_list(
2384 &mut self,
2385 title: &str,
2386 items: Vec<pi_tui::components::SelectItem>,
2387 ) -> Box<dyn Component> {
2388 let mut list = pi_tui::components::SelectList::new(
2389 items,
2390 super::selectors::SELECTOR_MAX_VISIBLE,
2391 super::theme::select_list_theme(),
2392 );
2393 list.set_selected_index(0);
2394 let select_tx = self.extension_select_tx.clone();
2395 list.on_select = Some(Box::new(move |item| {
2396 let _ = select_tx.send(item.value.clone());
2397 }));
2398 let cancel_tx = self.cancel_tx.clone();
2399 list.on_cancel = Some(Box::new(move || {
2400 let _ = cancel_tx.send(());
2401 }));
2402 title.clone_into(&mut self.view.editor.placeholder);
2403 Box::new(list)
2404 }
2405
2406 async fn begin_extension_dialog(&mut self, request: HostUiRequest) {
2407 if self.pending_extension_dialog.is_some() {
2408 self.cancel_extension_dialog().await;
2409 }
2410 let deadline = dialog_timeout(&request).map(|timeout| Instant::now() + timeout);
2411 let saved_editor_placeholder = self.view.editor.placeholder.clone();
2412 let mut saved_editor_text = None;
2413 match &request {
2414 HostUiRequest::Select { request, .. } => {
2415 let items = request
2416 .options
2417 .iter()
2418 .map(|option| {
2419 pi_tui::components::SelectItem::new(option.clone(), option.clone())
2420 })
2421 .collect();
2422 self.active_selector =
2423 Some(self.build_extension_select_list(&request.title, items));
2424 self.active_selector_kind = None;
2425 self.view.focus = FocusArea::Selector;
2426 }
2427 HostUiRequest::Confirm { request, .. } => {
2428 let items = vec![
2429 pi_tui::components::SelectItem::new("true", "Yes")
2430 .with_description(request.message.clone()),
2431 pi_tui::components::SelectItem::new("false", "No"),
2432 ];
2433 self.active_selector =
2434 Some(self.build_extension_select_list(&request.title, items));
2435 self.active_selector_kind = None;
2436 self.view.focus = FocusArea::Selector;
2437 }
2438 HostUiRequest::Input { request, .. } => {
2439 saved_editor_text = Some(self.editor.get_text());
2440 self.editor.set_text("");
2441 self.view.editor.text.clear();
2442 self.view.editor.placeholder = request
2443 .placeholder
2444 .clone()
2445 .unwrap_or_else(|| request.title.clone());
2446 self.view.focus = FocusArea::Editor;
2447 }
2448 HostUiRequest::Editor { request, .. } => {
2449 saved_editor_text = Some(self.editor.get_text());
2450 let prefill = request.prefill.clone().unwrap_or_default();
2451 self.editor.set_text(&prefill);
2452 self.view.editor.text = prefill;
2453 self.view.editor.placeholder.clone_from(&request.title);
2454 self.view.focus = FocusArea::Editor;
2455 }
2456 }
2457 self.pending_extension_dialog = Some(PendingExtensionDialog {
2458 request,
2459 saved_editor_text,
2460 saved_editor_placeholder,
2461 deadline,
2462 });
2463 self.input_state.reset_taps();
2464 self.arm_coalescer();
2465 }
2466
2467 async fn finish_extension_selection(&mut self, value: String) {
2468 let Some(dialog) = self.pending_extension_dialog.as_ref() else {
2469 return;
2470 };
2471 let response = match &dialog.request {
2472 HostUiRequest::Select { id, .. } => HostUiResponse::Select {
2473 id: *id,
2474 value: Some(value),
2475 },
2476 HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
2477 id: *id,
2478 confirmed: value == "true",
2479 },
2480 HostUiRequest::Input { .. } | HostUiRequest::Editor { .. } => return,
2481 };
2482 self.finish_extension_dialog(response).await;
2483 }
2484
2485 async fn cancel_extension_dialog(&mut self) {
2486 let Some(dialog) = self.pending_extension_dialog.as_ref() else {
2487 return;
2488 };
2489 let response = default_extension_dialog_response(&dialog.request);
2490 self.finish_extension_dialog(response).await;
2491 }
2492
2493 async fn finish_extension_dialog(&mut self, response: HostUiResponse) {
2494 let dialog = self.pending_extension_dialog.take();
2495 if let Some(runner) = &self.extension_runner
2496 && let Err(error) = runner.respond_ui(response).await
2497 {
2498 self.last_error = Some(error.to_string());
2499 }
2500 if let Some(dialog) = dialog {
2501 if let Some(saved) = dialog.saved_editor_text {
2502 self.editor.set_text(&saved);
2503 self.view.editor.text = saved;
2504 }
2505 self.view.editor.placeholder = dialog.saved_editor_placeholder;
2506 }
2507 self.close_selector();
2508 self.arm_coalescer();
2509 }
2510
2511 fn handle_extension_event(&mut self, event: ExtensionUiEvent) {
2512 match event {
2513 ExtensionUiEvent::Notify(notification) => {
2514 let severity = match notification.level {
2515 NotifyLevel::Info | NotifyLevel::Warning => DiagnosticSeverity::Warning,
2516 NotifyLevel::Error => DiagnosticSeverity::Error,
2517 };
2518 self.view.diagnostics.entries.push(StartupDiagnostic {
2519 severity,
2520 source: "extension".to_owned(),
2521 message: notification.message,
2522 });
2523 }
2524 ExtensionUiEvent::Slot(slot) => self.project_extension_slot(slot),
2525 ExtensionUiEvent::Dispose { key } => self.dispose_extension_slot(&key),
2526 }
2527 self.arm_coalescer();
2528 }
2529
2530 fn project_extension_slot(&mut self, slot: SanitizedSlot) {
2531 self.dispose_extension_slot(&slot.key);
2532 let non_capturing = slot
2533 .overlay_options
2534 .as_ref()
2535 .is_some_and(|options| options.non_capturing);
2536 let captures_focus = slot.focusable && !non_capturing;
2537 if captures_focus {
2538 for widget in self
2539 .view
2540 .widgets_above
2541 .iter_mut()
2542 .chain(self.view.widgets_below.iter_mut())
2543 {
2544 widget.focused = false;
2545 }
2546 }
2547 let widget = WidgetSlot {
2548 slot: slot.clone(),
2549 focused: captures_focus,
2550 };
2551 match slot.placement {
2552 SlotPlacement::Footer | SlotPlacement::BelowEditor => {
2553 self.view.widgets_below.push(widget);
2554 }
2555 SlotPlacement::Overlay => {
2556 self.view.overlay = Some(Overlay {
2557 kind: OverlayKind::Extension,
2558 height: slot.height,
2559 lines: Vec::new(),
2560 });
2561 self.view.extension_overlay_slot = Some(slot.clone());
2562 }
2563 SlotPlacement::Header
2564 | SlotPlacement::AboveEditor
2565 | SlotPlacement::Editor
2566 | SlotPlacement::MessageRenderer => self.view.widgets_above.push(widget),
2567 }
2568 if captures_focus {
2569 self.focused_extension_slot = Some(slot.key.clone());
2570 self.view.focus = if slot.placement == SlotPlacement::Overlay {
2571 FocusArea::Overlay
2572 } else {
2573 FocusArea::Widget
2574 };
2575 }
2576 self.extension_slots.insert(
2577 slot.key,
2578 ProjectedExtensionSlot {
2579 placement: slot.placement,
2580 generation: slot.generation,
2581 focusable: captures_focus,
2582 },
2583 );
2584 }
2585
2586 fn dispose_extension_slot(&mut self, key: &str) {
2587 self.view.widgets_above.retain(|slot| slot.slot.key != key);
2588 self.view.widgets_below.retain(|slot| slot.slot.key != key);
2589 if matches!(
2590 self.extension_slots.remove(key).map(|slot| slot.placement),
2591 Some(SlotPlacement::Overlay)
2592 ) && self
2593 .view
2594 .overlay
2595 .as_ref()
2596 .is_some_and(|overlay| overlay.kind == OverlayKind::Extension)
2597 {
2598 self.view.overlay = None;
2599 self.view.extension_overlay_slot = None;
2600 self.view.focus = FocusArea::Editor;
2601 }
2602 if self.focused_extension_slot.as_deref() == Some(key) {
2603 self.focused_extension_slot = None;
2604 self.view.focus = FocusArea::Editor;
2605 }
2606 }
2607
2608 async fn rebind_extension_channels(&mut self) {
2609 if self.pending_extension_dialog.is_some() {
2610 self.cancel_extension_dialog().await;
2611 }
2612 self.extension_runner = self.session.host_extension_runner();
2613 let current_slots = self
2614 .extension_runner
2615 .as_ref()
2616 .map_or_else(Vec::new, |runner| runner.current_slots());
2617 self.extension_events = self
2618 .extension_runner
2619 .as_ref()
2620 .map(|runner| runner.subscribe_ui());
2621 self.extension_requests = self
2622 .extension_runner
2623 .as_ref()
2624 .and_then(|runner| runner.take_ui_requests());
2625 self.pending_extension_dialog = None;
2626 self.extension_slots.clear();
2627 self.focused_extension_slot = None;
2628 self.view.extension_overlay_slot = None;
2629 self.effective_extension_shortcuts = self
2630 .extension_runner
2631 .as_ref()
2632 .map_or_else(Vec::new, |runner| {
2633 build_effective_extension_shortcuts(&runner.raw_shortcuts())
2634 });
2635 self.view.extension_shortcuts = shortcut_hints(&self.effective_extension_shortcuts);
2636 self.view.widgets_above.clear();
2637 self.view.widgets_below.clear();
2638 if self
2639 .view
2640 .overlay
2641 .as_ref()
2642 .is_some_and(|overlay| overlay.kind == OverlayKind::Extension)
2643 {
2644 self.view.overlay = None;
2645 }
2646 for slot in current_slots {
2647 self.project_extension_slot(slot);
2648 }
2649 }
2650
2651 fn route_extension_input(&mut self, event: &UiEvent) -> bool {
2652 if !matches!(event, UiEvent::Key(_) | UiEvent::Paste(_)) || is_global_app_binding(event) {
2653 return false;
2654 }
2655 if let Some(key) = self.focused_extension_slot.clone()
2656 && let Some(slot) = self.extension_slots.get(&key)
2657 && slot.focusable
2658 && let Some(runner) = self.extension_runner.as_ref()
2659 {
2660 let request = UiEventRequest {
2661 key,
2662 generation: slot.generation,
2663 event: ui_event_wire(event),
2664 data: encode_terminal_input(event),
2665 };
2666 let runner = Arc::clone(runner);
2667 let result_tx = self.extension_action_tx.clone();
2668 tokio::spawn(async move {
2669 let result = runner
2670 .send_ui_event(request)
2671 .await
2672 .map(|_| ())
2673 .map_err(|error| error.to_string());
2674 let _ = result_tx.send(result);
2675 });
2676 return true;
2677 }
2678
2679 let UiEvent::Key(key_event) = event else {
2680 return false;
2681 };
2682 if key_event.kind == crossterm::event::KeyEventKind::Release {
2683 return false;
2684 }
2685 let Some(shortcut) = self
2686 .effective_extension_shortcuts
2687 .iter()
2688 .find(|shortcut| key_matches_parsed(key_event, &shortcut.parsed))
2689 else {
2690 return false;
2691 };
2692 let Some(runner) = self.extension_runner.as_ref() else {
2693 return false;
2694 };
2695 let runner = Arc::clone(runner);
2696 let key = shortcut.dispatch_key.clone();
2697 let result_tx = self.extension_action_tx.clone();
2698 tokio::spawn(async move {
2699 let result = runner
2700 .execute_shortcut(key)
2701 .await
2702 .map(|_| ())
2703 .map_err(|error| error.to_string());
2704 let _ = result_tx.send(result);
2705 });
2706 true
2707 }
2708
2709 async fn intercept_terminal_input(&mut self, event: UiEvent) -> Option<UiEvent> {
2710 let Some(runner) = &self.extension_runner else {
2711 return Some(event);
2712 };
2713 if !runner.has_terminal_input_handlers() {
2714 return Some(event);
2715 }
2716 let Some(data) = encode_terminal_input(&event) else {
2717 return Some(event);
2718 };
2719 match runner.terminal_input(&data).await {
2720 Ok(result) if result.consume => None,
2721 Ok(result) => result
2722 .data
2723 .filter(|rewritten| rewritten != &data)
2724 .map_or(Some(event), |rewritten| {
2725 Some(decode_terminal_input(rewritten))
2726 }),
2727 Err(_) => Some(event),
2728 }
2729 }
2730
2731 fn ensure_editor_on_submit(&mut self) {
2732 if self.editor.on_submit.is_none() {
2733 let submit_tx = self.submit_tx.clone();
2734 self.editor.on_submit = Some(Box::new(move |text: String| {
2735 let _ = submit_tx.send(text);
2736 }));
2737 }
2738 }
2739
2740 pub async fn rebind_session_channels(&mut self) {
2743 self.events = self.session.subscribe();
2744 self.partial = self.session.partial_rx();
2745 let snapshot = self.session.snapshot();
2746 project_snapshot(&mut self.view, &snapshot, None);
2747 self.view.messages = project_messages(&self.session.messages());
2748 apply_display_preferences(
2749 &mut self.view.messages,
2750 self.display.tools_expanded,
2751 self.display.hide_thinking,
2752 );
2753 self.chat_prefix_cache = None;
2754 self.chat_prefix_len = usize::MAX;
2755 self.chat_tail_cache = None;
2756 self.chat_dirty = true;
2757 self.rebind_extension_channels().await;
2758 }
2759
2760 async fn refresh_footer(&mut self) {
2761 let snapshot = self.session.footer_snapshot().await;
2762 project_footer(&mut self.view, &snapshot);
2763 }
2764
2765 pub fn close_selector_for_suspend(&mut self) {
2767 self.close_selector();
2768 self.exited = false;
2769 }
2770
2771 fn set_status(&mut self, status: SessionStatus) {
2772 self.view.status = Some(status);
2773 }
2774
2775 fn record_err(&mut self, result: Result<(), String>) {
2778 if let Err(error) = result {
2779 self.record_error(error);
2780 }
2781 }
2782
2783 fn record_error(&mut self, error: String) {
2784 self.last_error = Some(error);
2785 self.arm_coalescer();
2786 }
2787
2788 fn surface_last_error(&mut self) {
2789 let Some(error) = self.last_error.as_ref() else {
2790 return;
2791 };
2792 if let Some(entry) = self
2793 .view
2794 .diagnostics
2795 .entries
2796 .iter_mut()
2797 .find(|entry| entry.source == "runtime")
2798 {
2799 entry.severity = DiagnosticSeverity::Error;
2800 entry.message.clone_from(error);
2801 } else {
2802 self.view.diagnostics.entries.push(StartupDiagnostic {
2803 severity: DiagnosticSeverity::Error,
2804 source: "runtime".to_owned(),
2805 message: error.clone(),
2806 });
2807 }
2808 }
2809
2810 fn refresh_chat_caches(&mut self) {
2815 let prefix_len = self.view.messages.len().saturating_sub(1);
2816 if self.chat_prefix_cache.is_none() || self.chat_prefix_len != prefix_len {
2817 let mut messages = std::mem::take(&mut self.view.messages);
2818 let tail = messages.split_off(prefix_len);
2819 self.view.messages = messages;
2820 self.chat_prefix_cache = Some(extract_chat_component(&self.view));
2821 let mut messages = std::mem::take(&mut self.view.messages);
2822 messages.extend(tail);
2823 self.view.messages = messages;
2824 self.chat_prefix_len = prefix_len;
2825 self.chat_dirty = true;
2826 }
2827
2828 if self.chat_tail_cache.is_none() || self.chat_dirty {
2829 let mut messages = std::mem::take(&mut self.view.messages);
2830 let tail = messages.split_off(prefix_len);
2831 let prefix = messages;
2832 self.view.messages = tail;
2833 self.chat_tail_cache = Some(extract_chat_component(&self.view));
2834 let mut tail = std::mem::take(&mut self.view.messages);
2835 let mut all = prefix;
2836 all.append(&mut tail);
2837 self.view.messages = all;
2838 self.chat_dirty = false;
2839 }
2840 }
2841
2842 fn build_root(
2843 &mut self,
2844 editor: Editor,
2845 selector: Option<Box<dyn Component>>,
2846 ) -> InteractiveRoot {
2847 self.surface_last_error();
2848 self.refresh_chat_caches();
2849 let prefix = self
2850 .chat_prefix_cache
2851 .take()
2852 .unwrap_or_else(empty_chat_component);
2853 let tail = self
2854 .chat_tail_cache
2855 .take()
2856 .unwrap_or_else(empty_chat_component);
2857 let dialog_title = self.pending_extension_dialog.as_ref().map(|dialog| {
2858 Box::new(pi_tui::components::Text::with_padding(
2859 super::theme::bold(&self.view.theme.fg(
2860 super::theme::ThemeColor::Accent,
2861 &extension_dialog_title(&dialog.request),
2862 )),
2863 1,
2864 0,
2865 )) as Box<dyn Component>
2866 });
2867 InteractiveRoot::build_with_chat(
2868 &mut self.view,
2869 editor,
2870 selector,
2871 dialog_title,
2872 prefix,
2873 tail,
2874 )
2875 }
2876
2877 fn recover_root(&mut self, mut root: InteractiveRoot) {
2878 self.chat_prefix_cache = root.take_section("chat-prefix");
2879 self.chat_tail_cache = root.take_section("chat-tail");
2880 self.editor = std::mem::replace(root.editor_mut(), Editor::with_defaults());
2881 self.active_selector = root.selector.take();
2882 }
2883
2884 fn arm_coalescer(&mut self) {
2885 if self.coalesce_deadline.is_none() {
2886 self.coalesce_deadline = Some(Instant::now() + BACKGROUND_COALESCE_WINDOW);
2887 }
2888 }
2889
2890 fn paint_frame(&mut self) -> io::Result<()> {
2891 let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2892 let saved_selector = self.active_selector.take();
2893 let mut root = self.build_root(saved_editor, saved_selector);
2894 let result = self.tui.commit(Txn::Frame, &mut root);
2895 self.recover_root(root);
2896 self.ensure_editor_on_submit();
2897 result
2898 }
2899
2900 fn commit_settle(&mut self, blocks: Vec<SettledBlock>) -> io::Result<()> {
2901 let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2902 let saved_selector = self.active_selector.take();
2903 let mut root = self.build_root(saved_editor, saved_selector);
2904 let result = self.tui.commit(Txn::Settle(blocks), &mut root);
2905 self.recover_root(root);
2906 self.ensure_editor_on_submit();
2907 result
2908 }
2909
2910 fn commit_reanchor(&mut self) -> io::Result<()> {
2911 let saved_editor = std::mem::replace(&mut self.editor, Editor::with_defaults());
2912 let saved_selector = self.active_selector.take();
2913 let mut root = self.build_root(saved_editor, saved_selector);
2914 let result = self
2915 .tui
2916 .commit(Txn::Reanchor(ReanchorCause::Resize), &mut root);
2917 self.recover_root(root);
2918 self.ensure_editor_on_submit();
2919 result
2920 }
2921
2922 pub async fn step_ui(&mut self, event: UiEvent) -> io::Result<()> {
2937 self.handle_ui_event(event).await
2938 }
2939
2940 pub fn step_session_event(
2946 &mut self,
2947 event: impl std::borrow::Borrow<AgentSessionEvent>,
2948 ) -> std::future::Ready<io::Result<()>> {
2949 self.handle_session_event(event.borrow());
2950 std::future::ready(Ok(()))
2951 }
2952
2953 pub fn paint_now(&mut self) -> io::Result<()> {
2959 self.paint_frame()
2960 }
2961
2962 pub fn flush_coalescer(&mut self) -> io::Result<()> {
2968 self.coalesce_deadline = None;
2969 self.paint_frame()
2970 }
2971
2972 pub fn enqueue_settle(&mut self, blocks: Vec<SettledBlock>) {
2974 self.pending_settle = Some(blocks);
2975 }
2976}
2977
2978fn project_snapshot(
2985 view: &mut ViewState,
2986 snapshot: &SessionSnapshot,
2987 partial: Option<&Arc<AssistantMessage>>,
2988) {
2989 view.streaming = snapshot.is_admission_active();
2990 view.status = match snapshot.activity {
2991 SessionActivity::Streaming => Some(SessionStatus {
2992 kind: StatusKind::Working,
2993 frame: 0,
2994 message: "Working…".to_owned(),
2995 }),
2996 SessionActivity::Compacting => Some(SessionStatus {
2997 kind: StatusKind::Compaction,
2998 frame: 0,
2999 message: "Compacting…".to_owned(),
3000 }),
3001 SessionActivity::Retrying => Some(SessionStatus {
3002 kind: StatusKind::Retry,
3003 frame: 0,
3004 message: "Retrying…".to_owned(),
3005 }),
3006 SessionActivity::Summarizing => Some(SessionStatus {
3007 kind: StatusKind::BranchSummary,
3008 frame: 0,
3009 message: "Summarizing…".to_owned(),
3010 }),
3011 SessionActivity::Idle if snapshot.is_admission_active() => Some(SessionStatus {
3012 kind: StatusKind::Working,
3013 frame: 0,
3014 message: "Working…".to_owned(),
3015 }),
3016 SessionActivity::Idle => None,
3017 };
3018
3019 view.pending.steering = snapshot
3020 .steering
3021 .iter()
3022 .map(|t| PendingMessage {
3023 kind: PendingKind::Steering,
3024 text: t.clone(),
3025 })
3026 .collect();
3027 view.pending.follow_up = snapshot
3028 .follow_up
3029 .iter()
3030 .map(|t| PendingMessage {
3031 kind: PendingKind::FollowUp,
3032 text: t.clone(),
3033 })
3034 .collect();
3035 view.pending.follow_up_mode = snapshot.follow_up_mode;
3036
3037 view.footer.model_id.clone_from(&snapshot.model_id);
3038 view.footer.flags.reasoning = snapshot.reasoning;
3039
3040 if let Some(message) = partial {
3041 let has_streaming = view
3042 .messages
3043 .iter_mut()
3044 .any(|m| matches!(m, MessageView::Assistant(v) if v.streaming));
3045 if !has_streaming {
3046 view.messages
3047 .push(MessageView::streaming_assistant((**message).clone()));
3048 }
3049 }
3050}
3051
3052fn project_footer(view: &mut ViewState, snapshot: &SessionFooterSnapshot) {
3053 let footer = &mut view.footer;
3054 footer.total_input = snapshot.total_input;
3055 footer.total_output = snapshot.total_output;
3056 footer.total_cache_read = snapshot.total_cache_read;
3057 footer.total_cache_write = snapshot.total_cache_write;
3058 footer.total_cost = snapshot.total_cost;
3059 footer.context_window = snapshot.context_window;
3060 footer.context_percent = snapshot.context_percent;
3061 footer.provider.clone_from(&snapshot.provider);
3062 footer.provider_count = snapshot.provider_count;
3063 footer.thinking_level = snapshot.thinking_level;
3064 footer.flags.billing = if snapshot.subscription {
3065 BillingMode::Subscription
3066 } else {
3067 BillingMode::Metered
3068 };
3069 footer.flags.auto_compact = snapshot.auto_compact;
3070 view.editor.border = if snapshot.bash_running {
3071 EditorBorder::Bash
3072 } else if snapshot.thinking_level == pi_ai::ModelThinkingLevel::Off {
3073 EditorBorder::Muted
3074 } else {
3075 EditorBorder::Thinking(snapshot.thinking_level)
3076 };
3077}
3078
3079const fn event_refreshes_footer(event: &AgentSessionEvent) -> bool {
3080 matches!(
3081 event,
3082 AgentSessionEvent::AgentSettled
3083 | AgentSessionEvent::CompactionEnd { .. }
3084 | AgentSessionEvent::ThinkingLevelChanged { .. }
3085 )
3086}
3087
3088fn project_event(view: &mut ViewState, event: &AgentSessionEvent) {
3090 use crate::core::agent_session::events::AgentSessionEvent as Event;
3091
3092 match event {
3093 Event::AgentStart => {
3094 view.streaming = true;
3095 view.status = Some(SessionStatus {
3096 kind: StatusKind::Working,
3097 frame: 0,
3098 message: "Working…".to_owned(),
3099 });
3100 }
3101 Event::AgentEnd { will_retry, .. } => {
3102 if !will_retry {
3103 view.streaming = false;
3104 view.status = None;
3105 }
3106 }
3107 Event::AgentSettled => {
3108 view.streaming = false;
3109 view.status = None;
3110 }
3111 Event::TurnStart
3112 | Event::TurnEnd { .. }
3113 | Event::SessionBeforeSwitch { .. }
3114 | Event::SessionBeforeFork { .. }
3115 | Event::SessionStart { .. }
3116 | Event::SessionShutdown { .. }
3117 | Event::ModelSelect { .. } => {}
3118 Event::MessageStart { message } => project_message_start(view, message),
3119 Event::MessageUpdate { message, .. } => {
3120 project_assistant_message(view, message, false);
3121 }
3122 Event::MessageEnd { message } => project_assistant_message(view, message, true),
3123 Event::ToolExecutionStart {
3124 tool_call_id,
3125 tool_name,
3126 args,
3127 } => project_tool_start(view, tool_call_id, tool_name, args),
3128 Event::ToolExecutionUpdate {
3129 tool_call_id,
3130 partial_result,
3131 ..
3132 } => update_tool_message(
3133 view,
3134 tool_call_id,
3135 Some(partial_result),
3136 false,
3137 super::tool_renderer::ToolPhase::Pending,
3138 ),
3139 Event::ToolExecutionEnd {
3140 tool_call_id,
3141 result,
3142 is_error,
3143 ..
3144 } => project_tool_end(view, tool_call_id, result, *is_error),
3145 Event::QueueUpdate {
3146 steering,
3147 follow_up,
3148 } => project_queue(view, steering, follow_up),
3149 Event::CompactionStart { reason } => project_compaction_start(view, *reason),
3150 Event::CompactionEnd { .. } | Event::AutoRetryEnd { .. } => view.status = None,
3151 Event::EntryAppended { entry } => project_entry(view, entry),
3152 Event::SessionInfoChanged { name } => view.footer.session_name.clone_from(name),
3153 Event::ThinkingLevelChanged { level } => {
3154 view.footer.thinking_level = *level;
3155 }
3156 Event::AutoRetryStart {
3157 attempt,
3158 max_attempts,
3159 delay_ms,
3160 ..
3161 } => {
3162 view.status = Some(SessionStatus {
3163 kind: StatusKind::Retry,
3164 frame: 0,
3165 message: format!(
3166 "Retrying ({}/{}) in {}s",
3167 attempt,
3168 max_attempts,
3169 delay_ms / 1000
3170 ),
3171 });
3172 }
3173 }
3174}
3175
3176fn project_message_start(view: &mut ViewState, message: &pi_agent::AgentMessage) {
3177 let Some(view_message) = message_view_from_agent(message) else {
3178 return;
3179 };
3180 if matches!(view_message, MessageView::Assistant(_)) {
3181 let has_streaming = view
3182 .messages
3183 .iter()
3184 .any(|message| matches!(message, MessageView::Assistant(item) if item.streaming));
3185 if !has_streaming {
3186 view.messages.push(view_message);
3187 }
3188 } else {
3189 view.messages.push(view_message);
3190 }
3191}
3192
3193fn project_assistant_message(
3194 view: &mut ViewState,
3195 message: &pi_agent::AgentMessage,
3196 finished: bool,
3197) {
3198 let pi_agent::AgentMessage::Llm(boxed) = message else {
3199 return;
3200 };
3201 let pi_ai::Message::Assistant(assistant_message) = boxed.as_ref() else {
3202 return;
3203 };
3204
3205 for message in &mut view.messages {
3206 if let MessageView::Assistant(assistant) = message
3207 && assistant.streaming
3208 {
3209 assistant.streaming = !finished;
3210 assistant.message.clone_from(assistant_message);
3211 return;
3212 }
3213 }
3214
3215 if finished {
3216 view.messages
3217 .push(MessageView::Assistant(AssistantMessageView {
3218 message: assistant_message.clone(),
3219 hide_thinking: false,
3220 hidden_thinking_label: String::new(),
3221 streaming: false,
3222 }));
3223 } else {
3224 view.messages
3225 .push(MessageView::streaming_assistant(assistant_message.clone()));
3226 }
3227}
3228
3229fn project_tool_start(
3230 view: &mut ViewState,
3231 tool_call_id: &str,
3232 tool_name: &str,
3233 args: &serde_json::Map<String, serde_json::Value>,
3234) {
3235 let args_value = serde_json::Value::Object(args.clone());
3236 let args_summary = summarize_tool_args(&args_value);
3237 view.messages
3238 .push(MessageView::Tool(super::messages::ToolMessageView {
3239 renderer: tool_name.to_owned(),
3240 state: super::tool_renderer::ToolState {
3241 call: super::tool_renderer::ToolCallView {
3242 name: tool_name.to_owned(),
3243 id: tool_call_id.to_owned(),
3244 args_summary,
3245 raw_args: args_value,
3246 },
3247 result: None,
3248 expanded: false,
3249 phase: super::tool_renderer::ToolPhase::Pending,
3250 },
3251 }));
3252}
3253
3254fn project_tool_end(
3255 view: &mut ViewState,
3256 tool_call_id: &str,
3257 result: &pi_agent::AgentToolResult,
3258 is_error: bool,
3259) {
3260 let phase = if is_error {
3261 super::tool_renderer::ToolPhase::Error
3262 } else {
3263 super::tool_renderer::ToolPhase::Success
3264 };
3265 update_tool_message(view, tool_call_id, Some(result), is_error, phase);
3266}
3267
3268fn project_queue(view: &mut ViewState, steering: &[String], follow_up: &[String]) {
3269 view.pending.steering = steering
3270 .iter()
3271 .map(|text| PendingMessage {
3272 kind: PendingKind::Steering,
3273 text: text.clone(),
3274 })
3275 .collect();
3276 view.pending.follow_up = follow_up
3277 .iter()
3278 .map(|text| PendingMessage {
3279 kind: PendingKind::FollowUp,
3280 text: text.clone(),
3281 })
3282 .collect();
3283}
3284
3285fn project_compaction_start(
3286 view: &mut ViewState,
3287 reason: crate::core::agent_session::events::CompactionReason,
3288) {
3289 let message = match reason {
3290 crate::core::agent_session::events::CompactionReason::Manual => "Compacting…",
3291 crate::core::agent_session::events::CompactionReason::Threshold => "Auto-compacting…",
3292 crate::core::agent_session::events::CompactionReason::Overflow => "Overflow auto-compact…",
3293 };
3294 view.status = Some(SessionStatus {
3295 kind: StatusKind::Compaction,
3296 frame: 0,
3297 message: message.to_owned(),
3298 });
3299}
3300
3301fn project_entry(view: &mut ViewState, entry: &crate::core::sessions::SessionEntry) {
3302 let Some(view_message) = message_view_from_entry(entry) else {
3303 return;
3304 };
3305 match &view_message {
3306 MessageView::User(user) => {
3307 let already_present = view.messages.iter().rev().any(
3308 |message| matches!(message, MessageView::User(item) if item.text == user.text),
3309 );
3310 if !already_present {
3311 view.messages.push(view_message);
3312 }
3313 }
3314 MessageView::Assistant(_) => {
3315 }
3317 MessageView::Tool(_)
3318 | MessageView::Bash(_)
3319 | MessageView::Custom(_)
3320 | MessageView::Compaction(_)
3321 | MessageView::Branch(_)
3322 | MessageView::Skill(_) => view.messages.push(view_message),
3323 }
3324}
3325
3326fn project_messages(messages: &[pi_agent::AgentMessage]) -> Vec<MessageView> {
3331 messages
3332 .iter()
3333 .filter_map(message_view_from_agent)
3334 .collect()
3335}
3336
3337fn extract_chat_component(view: &ViewState) -> Box<dyn Component> {
3338 compose(view)
3339 .sections
3340 .into_iter()
3341 .find(|section| section.label == "chat")
3342 .map_or_else(empty_chat_component, |section| section.component)
3343}
3344
3345fn empty_chat_component() -> Box<dyn Component> {
3346 Box::new(pi_tui::components::Text::new(String::new()))
3347}
3348
3349fn apply_display_preferences(
3350 messages: &mut [MessageView],
3351 tools_expanded: bool,
3352 hide_thinking: bool,
3353) {
3354 for message in messages {
3355 match message {
3356 MessageView::Assistant(view) => view.hide_thinking = hide_thinking,
3357 MessageView::Tool(view) => view.state.expanded = tools_expanded,
3358 MessageView::Bash(view) => view.expanded = tools_expanded,
3359 MessageView::User(_)
3360 | MessageView::Custom(_)
3361 | MessageView::Compaction(_)
3362 | MessageView::Branch(_)
3363 | MessageView::Skill(_) => {}
3364 }
3365 }
3366}
3367
3368fn message_view_from_agent(message: &pi_agent::AgentMessage) -> Option<MessageView> {
3369 match message {
3370 pi_agent::AgentMessage::Llm(boxed) => match boxed.as_ref() {
3371 pi_ai::Message::User(user) => {
3372 Some(MessageView::User(super::messages::UserMessageView {
3373 text: user_message_text(user),
3374 }))
3375 }
3376 pi_ai::Message::Assistant(am) => Some(MessageView::Assistant(
3377 super::messages::AssistantMessageView {
3378 message: am.clone(),
3379 hide_thinking: false,
3380 hidden_thinking_label: String::new(),
3381 streaming: false,
3382 },
3383 )),
3384 pi_ai::Message::ToolResult(_) => None,
3385 },
3386 pi_agent::AgentMessage::Custom(custom) => Some(message_view_from_custom(custom)),
3387 }
3388}
3389
3390fn message_view_from_custom(custom: &pi_agent::CustomAgentMessage) -> MessageView {
3391 let text = custom
3392 .payload
3393 .get("text")
3394 .and_then(serde_json::Value::as_str)
3395 .or_else(|| {
3396 custom
3397 .payload
3398 .get("content")
3399 .and_then(serde_json::Value::as_str)
3400 })
3401 .unwrap_or("")
3402 .to_owned();
3403 match custom.role.as_str() {
3404 "bashExecution" => bash_message_view(custom, &text),
3405 "compactionSummary" => MessageView::Compaction(super::messages::CompactionSummaryView {
3406 summary: custom
3407 .payload
3408 .get("summary")
3409 .and_then(serde_json::Value::as_str)
3410 .unwrap_or(&text)
3411 .to_owned(),
3412 tokens_before: custom
3413 .payload
3414 .get("tokensBefore")
3415 .and_then(serde_json::Value::as_i64)
3416 .unwrap_or(0),
3417 }),
3418 "branchSummary" => MessageView::Branch(super::messages::BranchSummaryView {
3419 summary: custom
3420 .payload
3421 .get("summary")
3422 .and_then(serde_json::Value::as_str)
3423 .unwrap_or(&text)
3424 .to_owned(),
3425 from_id: custom
3426 .payload
3427 .get("fromId")
3428 .and_then(serde_json::Value::as_str)
3429 .unwrap_or("root")
3430 .to_owned(),
3431 }),
3432 "skillInvocation" => MessageView::Skill(super::messages::SkillInvocationView {
3433 name: custom
3434 .payload
3435 .get("name")
3436 .and_then(serde_json::Value::as_str)
3437 .unwrap_or("skill")
3438 .to_owned(),
3439 text,
3440 }),
3441 other => MessageView::Custom(super::messages::CustomMessageView {
3442 custom_type: other.to_owned(),
3443 text,
3444 }),
3445 }
3446}
3447
3448fn bash_message_view(custom: &pi_agent::CustomAgentMessage, text: &str) -> MessageView {
3449 let command = custom
3450 .payload
3451 .get("command")
3452 .and_then(serde_json::Value::as_str)
3453 .unwrap_or("")
3454 .to_owned();
3455 let output = custom
3456 .payload
3457 .get("output")
3458 .and_then(serde_json::Value::as_str)
3459 .unwrap_or(text)
3460 .to_owned();
3461 MessageView::Bash(super::messages::BashMessageView {
3462 command,
3463 output,
3464 expanded: false,
3465 exit_code: custom
3466 .payload
3467 .get("exitCode")
3468 .and_then(serde_json::Value::as_i64)
3469 .map(clamp_i64_to_i32),
3470 cancelled: custom
3471 .payload
3472 .get("cancelled")
3473 .and_then(serde_json::Value::as_bool)
3474 .unwrap_or(false),
3475 truncated: custom
3476 .payload
3477 .get("truncated")
3478 .and_then(serde_json::Value::as_bool)
3479 .unwrap_or(false),
3480 full_output_path: custom
3481 .payload
3482 .get("fullOutputPath")
3483 .and_then(serde_json::Value::as_str)
3484 .map(str::to_owned),
3485 })
3486}
3487
3488fn message_view_from_entry(entry: &crate::core::sessions::SessionEntry) -> Option<MessageView> {
3489 use crate::core::sessions::SessionEntry;
3490 match entry {
3491 SessionEntry::Message(m) => message_view_from_agent(&m.message),
3492 SessionEntry::Compaction(c) => Some(MessageView::Compaction(
3493 super::messages::CompactionSummaryView {
3494 summary: c.summary.clone(),
3495 tokens_before: c.tokens_before,
3496 },
3497 )),
3498 SessionEntry::BranchSummary(b) => {
3499 Some(MessageView::Branch(super::messages::BranchSummaryView {
3500 summary: b.summary.clone(),
3501 from_id: b.from_id.clone(),
3502 }))
3503 }
3504 SessionEntry::CustomMessage(m) => {
3505 let text = match &m.content {
3506 crate::core::messages::CustomMessageContent::Text(s) => s.clone(),
3507 crate::core::messages::CustomMessageContent::Blocks(blocks) => blocks
3508 .iter()
3509 .filter_map(|b| match b {
3510 pi_ai::UserContent::Text(t) => Some(t.text.to_string()),
3511 pi_ai::UserContent::Image(_) => None,
3512 })
3513 .collect::<String>(),
3514 };
3515 Some(MessageView::Custom(super::messages::CustomMessageView {
3516 custom_type: m.custom_type.clone(),
3517 text,
3518 }))
3519 }
3520 SessionEntry::Custom(c) => Some(MessageView::Custom(super::messages::CustomMessageView {
3521 custom_type: c.custom_type.clone(),
3522 text: c
3523 .data
3524 .as_ref()
3525 .and_then(|v| v.as_str())
3526 .unwrap_or("")
3527 .to_owned(),
3528 })),
3529 _ => None,
3530 }
3531}
3532
3533fn user_message_text(user: &pi_ai::UserMessage) -> String {
3534 match &user.content {
3535 pi_ai::UserMessageContent::Text(s) => s.clone(),
3536 pi_ai::UserMessageContent::Blocks(blocks) => blocks
3537 .iter()
3538 .filter_map(|b| match b {
3539 pi_ai::UserContent::Text(t) => Some(t.text.to_string()),
3540 pi_ai::UserContent::Image(_) => None,
3541 })
3542 .collect::<String>(),
3543 }
3544}
3545
3546fn clamp_i64_to_i32(value: i64) -> i32 {
3547 match i32::try_from(value) {
3548 Ok(value) => value,
3549 Err(_) if value.is_negative() => i32::MIN,
3550 Err(_) => i32::MAX,
3551 }
3552}
3553
3554fn summarize_tool_args(args: &serde_json::Value) -> String {
3555 match args {
3556 serde_json::Value::Object(map) if map.len() == 1 => map.iter().next().map_or_else(
3557 || args.to_string(),
3558 |(key, value)| match value {
3559 serde_json::Value::String(text) => format!("{key}={text}"),
3560 other => format!("{key}={other}"),
3561 },
3562 ),
3563 other => other.to_string(),
3564 }
3565}
3566
3567fn tool_result_view(
3568 result: &pi_agent::AgentToolResult,
3569 is_error: bool,
3570) -> super::tool_renderer::ToolResultView {
3571 let mut text = String::new();
3572 for content in &result.content {
3573 if let pi_ai::ToolResultContent::Text(t) = content {
3574 if !text.is_empty() {
3575 text.push('\n');
3576 }
3577 text.push_str(&t.text.read());
3578 }
3579 }
3580 super::tool_renderer::ToolResultView {
3581 text,
3582 truncated: false,
3583 full_output_path: None,
3584 images: Vec::new(),
3585 error: if is_error {
3586 Some(
3587 result
3588 .details
3589 .get("error")
3590 .and_then(|v| v.as_str())
3591 .unwrap_or("tool error")
3592 .to_owned(),
3593 )
3594 } else {
3595 None
3596 },
3597 }
3598}
3599
3600fn update_tool_message(
3601 view: &mut ViewState,
3602 tool_call_id: &str,
3603 result: Option<&pi_agent::AgentToolResult>,
3604 is_error: bool,
3605 phase: super::tool_renderer::ToolPhase,
3606) {
3607 for message in view.messages.iter_mut().rev() {
3608 if let MessageView::Tool(tool) = message
3609 && tool.state.call.id == tool_call_id
3610 {
3611 if let Some(result) = result {
3612 tool.state.result = Some(tool_result_view(result, is_error));
3613 }
3614 tool.state.phase = phase;
3615 return;
3616 }
3617 }
3618}
3619
3620#[cfg(test)]
3626fn settled_lines(lines: Vec<Line<'static>>) -> SettledBlock {
3627 SettledBlock::Lines(lines)
3628}
3629
3630#[allow(dead_code)]
3631fn settled_raw(rows: u16, bytes: Vec<u8>, fallback: Vec<Line<'static>>) -> SettledBlock {
3632 SettledBlock::Raw {
3633 rows,
3634 bytes,
3635 kitty_id: None,
3636 fallback,
3637 }
3638}
3639
3640#[derive(Clone, Default)]
3648pub struct SharedWriter {
3649 inner: Arc<std::sync::Mutex<Vec<u8>>>,
3650}
3651
3652impl SharedWriter {
3653 #[must_use]
3655 pub fn new() -> Self {
3656 Self::default()
3657 }
3658
3659 #[must_use]
3661 pub fn snapshot(&self) -> Vec<u8> {
3662 self.inner.lock().map(|g| g.clone()).unwrap_or_default()
3663 }
3664}
3665
3666impl Write for SharedWriter {
3667 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
3668 let mut guard = self
3669 .inner
3670 .lock()
3671 .map_err(|_| io::Error::other("shared writer poisoned"))?;
3672 guard.extend_from_slice(buf);
3673 Ok(buf.len())
3674 }
3675
3676 fn flush(&mut self) -> io::Result<()> {
3677 Ok(())
3678 }
3679}
3680
3681impl Debug for SharedWriter {
3682 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3683 f.debug_struct("SharedWriter").finish_non_exhaustive()
3684 }
3685}
3686
3687#[must_use]
3689pub fn mock_input(rx: mpsc::UnboundedReceiver<UiEvent>) -> TerminalInput {
3690 TerminalInput::mock(rx)
3691}
3692
3693use std::io::IsTerminal;
3698
3699use crate::core::agent_session::bash::ExecuteBashOptions;
3700use crate::core::agent_session::{AgentSession, AgentSessionEventListener};
3701use crate::core::agent_session_runtime::{
3702 AgentSessionRuntime, AgentSessionRuntimeError, ForkPosition, NewSessionOptions,
3703 SwitchSessionOptions,
3704};
3705use pi_tui::terminal::{
3706 TerminalGuard, install_panic_emergency_hook, write_emergency_restore_bytes,
3707};
3708
3709#[derive(Clone)]
3717pub struct AgentSessionHost {
3718 session: Arc<std::sync::RwLock<Arc<AgentSession>>>,
3719 runtime: Arc<AgentSessionRuntime>,
3720}
3721
3722impl AgentSessionHost {
3723 #[must_use]
3725 pub fn new(runtime: Arc<AgentSessionRuntime>) -> Self {
3726 let session = runtime.session();
3727 Self {
3728 session: Arc::new(std::sync::RwLock::new(session)),
3729 runtime,
3730 }
3731 }
3732
3733 #[must_use]
3735 pub fn session(&self) -> Arc<AgentSession> {
3736 self.read_session()
3737 }
3738
3739 pub fn refresh(&self) {
3741 let next = self.runtime.session();
3742 if let Ok(mut guard) = self.session.write() {
3743 guard.clone_from(&next);
3744 }
3745 }
3746
3747 fn read_session(&self) -> Arc<AgentSession> {
3748 self.session.read().map_or_else(
3749 |poisoned| Arc::clone(&*poisoned.into_inner()),
3750 |guard| Arc::clone(&*guard),
3751 )
3752 }
3753}
3754
3755impl SessionHost for AgentSessionHost {
3756 fn snapshot(&self) -> SessionSnapshot {
3757 let session = self.read_session();
3758 let model = session.model();
3759 let thinking = session.thinking_level();
3760 let admission_active = session.is_admission_active();
3761 let activity = if session.is_compacting() {
3762 SessionActivity::Compacting
3763 } else if session.is_retrying() {
3764 SessionActivity::Retrying
3765 } else if session.is_summarizing() {
3766 SessionActivity::Summarizing
3767 } else if admission_active {
3768 SessionActivity::Streaming
3769 } else {
3770 SessionActivity::Idle
3771 };
3772 let (steering, follow_up) = session.pending_messages();
3773 SessionSnapshot {
3774 activity,
3775 admission_active,
3776 bash_running: session.is_bash_running(),
3777 thinking_level_label: format!("{thinking:?}").to_lowercase(),
3778 model_id: model.id.clone(),
3779 reasoning: model.reasoning,
3780 steering,
3781 follow_up,
3782 follow_up_mode: match session.follow_up_mode() {
3783 pi_agent::QueueMode::All => super::state::QueueMode::All,
3784 pi_agent::QueueMode::OneAtATime => super::state::QueueMode::OneAtATime,
3785 },
3786 }
3787 }
3788
3789 fn footer_snapshot(&self) -> BoxFuture<'_, SessionFooterSnapshot> {
3790 let session = self.read_session();
3791 Box::pin(async move {
3792 let model = session.model();
3793 let stats = session.get_session_stats().await;
3794 let context = stats.context_usage;
3795 let runtime = session.model_runtime_handle();
3796 let subscription = runtime
3797 .as_ref()
3798 .is_some_and(|runtime| runtime.is_using_oauth(&model.provider));
3799 let provider_count = runtime.as_ref().map_or(1, |runtime| {
3800 runtime
3801 .get_models(None)
3802 .into_iter()
3803 .map(|model| model.provider)
3804 .collect::<std::collections::BTreeSet<_>>()
3805 .len()
3806 .max(1)
3807 });
3808 SessionFooterSnapshot {
3809 total_input: stats.tokens.input,
3810 total_output: stats.tokens.output,
3811 total_cache_read: stats.tokens.cache_read,
3812 total_cache_write: stats.tokens.cache_write,
3813 total_cost: stats.cost,
3814 context_window: context.map_or(model.context_window, |usage| usage.context_window),
3815 context_percent: context.and_then(|usage| usage.percent),
3816 provider: Some(model.provider),
3817 provider_count,
3818 thinking_level: session.thinking_level(),
3819 bash_running: session.is_bash_running(),
3820 subscription,
3821 auto_compact: session.auto_compaction_enabled(),
3822 }
3823 })
3824 }
3825
3826 fn subscribe(&self) -> EventSubscription {
3827 let (tx, rx) = mpsc::unbounded_channel::<AgentSessionEvent>();
3828 let session = self.read_session();
3829 let listener: AgentSessionEventListener = Arc::new(move |event: &AgentSessionEvent| {
3830 let _ = tx.send(event.clone());
3831 });
3832 let unsubscribe = session.subscribe_arc_listener(listener);
3833 EventSubscription {
3834 rx,
3835 unsubscribe: Some(Box::new(unsubscribe)),
3836 }
3837 }
3838
3839 fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>> {
3840 self.read_session().agent().partial()
3841 }
3842
3843 fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>> {
3844 let session = self.read_session();
3845 let text = text.to_owned();
3846 Box::pin(async move { session.prompt(&text, opts).await.map_err(|e| e.to_string()) })
3847 }
3848
3849 fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
3850 let session = self.read_session();
3851 let text = text.to_owned();
3852 Box::pin(async move { session.steer(&text, Vec::new()).map_err(|e| e.to_string()) })
3853 }
3854
3855 fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
3856 let session = self.read_session();
3857 let text = text.to_owned();
3858 Box::pin(async move {
3859 session
3860 .follow_up(&text, Vec::new())
3861 .map_err(|e| e.to_string())
3862 })
3863 }
3864
3865 fn abort(&self) -> BoxFuture<'static, Result<(), String>> {
3866 let session = self.read_session();
3867 Box::pin(async move {
3868 session.abort().await;
3869 Ok(())
3870 })
3871 }
3872
3873 fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>> {
3874 let session = self.read_session();
3875 let instructions = instructions.map(str::to_owned);
3876 Box::pin(async move {
3877 session
3878 .compact(instructions.as_deref())
3879 .await
3880 .map(|_| ())
3881 .map_err(|e| e.to_string())
3882 })
3883 }
3884
3885 fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>> {
3886 let session = self.read_session();
3887 Box::pin(async move {
3888 session
3889 .cycle_thinking_level()
3890 .await
3891 .ok_or_else(|| "model does not support thinking".to_owned())
3892 .map(|_| ())
3893 })
3894 }
3895
3896 fn cycle_model(&self, forward: bool) -> BoxFuture<'_, Result<(), String>> {
3897 let session = self.read_session();
3898 Box::pin(async move {
3899 let direction = if forward {
3900 crate::core::agent_session::model::CycleDirection::Forward
3901 } else {
3902 crate::core::agent_session::model::CycleDirection::Backward
3903 };
3904 session
3905 .cycle_model(direction)
3906 .await
3907 .ok_or_else(|| "only one model available".to_owned())
3908 .map(|_| ())
3909 })
3910 }
3911
3912 fn reload(&self) -> BoxFuture<'_, Result<(), String>> {
3913 let session = self.read_session();
3914 Box::pin(async move { session.reload().await.map_err(|e| e.to_string()) })
3915 }
3916
3917 fn messages(&self) -> Vec<pi_agent::AgentMessage> {
3918 self.read_session().messages()
3919 }
3920
3921 fn host_extension_runner(&self) -> Option<Arc<HostExtensionRunner>> {
3922 self.read_session().host_extension_runner()
3923 }
3924
3925 fn hide_thinking_block(&self) -> bool {
3926 self.read_session()
3927 .lock_settings()
3928 .get_hide_thinking_block()
3929 }
3930
3931 fn set_hide_thinking_block(&self, hide: bool) -> Result<(), String> {
3932 self.read_session()
3933 .lock_settings()
3934 .set_hide_thinking_block(hide);
3935 Ok(())
3936 }
3937
3938 fn external_editor_command(&self) -> String {
3939 self.read_session()
3940 .lock_settings()
3941 .get_external_editor_command()
3942 }
3943
3944 fn get_model_entries(
3945 &self,
3946 ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>> {
3947 let session = self.read_session();
3948 Box::pin(async move {
3949 let models = session
3950 .model_runtime_handle()
3951 .map_or_else(|| vec![session.model()], |runtime| runtime.get_models(None));
3952 Ok(models
3953 .into_iter()
3954 .map(|m| super::state::ModelSelectorEntry {
3955 value: format!("{}/{}", m.provider, m.id),
3956 label: if m.name.is_empty() {
3957 m.id.clone()
3958 } else {
3959 m.name.clone()
3960 },
3961 description: Some(m.provider.clone()),
3962 })
3963 .collect())
3964 })
3965 }
3966
3967 fn get_session_entries(
3968 &self,
3969 ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>> {
3970 let session = self.read_session();
3971 Box::pin(async move {
3972 let cwd = session.cwd.clone();
3973 let session_dir = {
3974 let manager = session.session_manager();
3975 let sm = manager.lock().await;
3976 sm.get_session_dir().to_owned()
3977 };
3978 let dir = if session_dir.is_empty() {
3979 crate::core::config::get_sessions_dir()
3980 } else {
3981 std::path::PathBuf::from(session_dir)
3982 };
3983 let infos = crate::core::sessions::list_sessions_for_cwd(&cwd, &dir, true, None).await;
3984 Ok(infos
3985 .into_iter()
3986 .map(|info| {
3987 let label = info
3988 .name
3989 .clone()
3990 .filter(|n| !n.is_empty())
3991 .unwrap_or_else(|| {
3992 if info.first_message.is_empty() {
3993 info.path.clone()
3994 } else {
3995 info.first_message.chars().take(80).collect()
3996 }
3997 });
3998 super::state::SessionPickerEntry {
3999 value: info.path,
4000 label,
4001 description: Some(format!("{} msgs", info.message_count)),
4002 }
4003 })
4004 .collect())
4005 })
4006 }
4007
4008 fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
4009 let session = self.read_session();
4010 Box::pin(async move {
4011 let manager = session.session_manager();
4012 let sm = manager.lock().await;
4013 let tree = sm.get_tree();
4014 let mut out = Vec::new();
4015 flatten_tree_nodes(&tree, 0, &mut out);
4016 Ok(out)
4017 })
4018 }
4019
4020 fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
4021 let session = self.read_session();
4022 Box::pin(async move {
4023 let users = session.get_user_messages_for_forking().await;
4024 Ok(users
4025 .into_iter()
4026 .map(|u| super::state::TreeEntry {
4027 value: u.entry_id,
4028 label: u.text.chars().take(80).collect(),
4029 depth: 0,
4030 })
4031 .collect())
4032 })
4033 }
4034
4035 fn get_trust_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4036 let session = self.read_session();
4037 Box::pin(async move {
4038 let settings = session.lock_settings();
4039 let trust = settings.get_default_project_trust();
4040 Ok(vec![super::state::SettingsRow {
4041 id: "defaultProjectTrust".to_owned(),
4042 label: "Default project trust".to_owned(),
4043 description: Some("Trust policy for newly discovered project dirs".to_owned()),
4044 current_value: format!("{trust:?}").to_lowercase(),
4045 values: Some(vec![
4046 "ask".to_owned(),
4047 "always".to_owned(),
4048 "never".to_owned(),
4049 ]),
4050 }])
4051 })
4052 }
4053
4054 fn get_auth_entries(
4055 &self,
4056 ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>> {
4057 let session = self.read_session();
4058 Box::pin(async move {
4059 let mut out = Vec::new();
4060 if let Some(runtime) = session.model_runtime_handle() {
4061 for provider in runtime.get_registered_provider_ids() {
4062 let configured = runtime.has_configured_auth(&provider);
4063 out.push(super::state::AuthSelectorEntry {
4064 value: provider.clone(),
4065 label: provider.clone(),
4066 description: Some(if configured {
4067 "configured".to_owned()
4068 } else {
4069 "not configured".to_owned()
4070 }),
4071 });
4072 }
4073 }
4074 if out.is_empty() {
4075 let model = session.model();
4076 out.push(super::state::AuthSelectorEntry {
4077 value: model.provider.clone(),
4078 label: model.provider.clone(),
4079 description: Some("active provider".to_owned()),
4080 });
4081 }
4082 Ok(out)
4083 })
4084 }
4085
4086 fn get_scoped_models_entries(&self) -> BoxFuture<'_, Result<ScopedModelEntries, String>> {
4087 let session = self.read_session();
4088 Box::pin(async move {
4089 let scoped = session.scoped_models();
4090 let mut enabled = std::collections::BTreeMap::new();
4091 let entries = scoped
4092 .into_iter()
4093 .map(|sm| {
4094 let value = format!("{}/{}", sm.model.provider, sm.model.id);
4095 enabled.insert(value.clone(), true);
4096 super::state::ModelSelectorEntry {
4097 value,
4098 label: if sm.model.name.is_empty() {
4099 sm.model.id.clone()
4100 } else {
4101 sm.model.name.clone()
4102 },
4103 description: Some(sm.model.provider.clone()),
4104 }
4105 })
4106 .collect();
4107 Ok((entries, enabled))
4108 })
4109 }
4110
4111 fn get_settings_entries(
4112 &self,
4113 ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4114 let session = self.read_session();
4115 Box::pin(async move {
4116 let settings = session.lock_settings();
4117 Ok(vec![
4118 super::state::SettingsRow {
4119 id: "theme".to_owned(),
4120 label: "Theme".to_owned(),
4121 description: Some("Color scheme".to_owned()),
4122 current_value: settings.get_theme().unwrap_or_else(|| "default".to_owned()),
4123 values: Some(vec!["dark".to_owned(), "light".to_owned()]),
4124 },
4125 super::state::SettingsRow {
4126 id: "compaction.enabled".to_owned(),
4127 label: "Auto-compact".to_owned(),
4128 description: Some("Automatically compact long contexts".to_owned()),
4129 current_value: if settings.get_compaction_enabled() {
4130 "on".to_owned()
4131 } else {
4132 "off".to_owned()
4133 },
4134 values: Some(vec!["on".to_owned(), "off".to_owned()]),
4135 },
4136 super::state::SettingsRow {
4137 id: "retry.enabled".to_owned(),
4138 label: "Auto-retry".to_owned(),
4139 description: Some("Retry transient provider errors".to_owned()),
4140 current_value: if settings.get_retry_enabled() {
4141 "on".to_owned()
4142 } else {
4143 "off".to_owned()
4144 },
4145 values: Some(vec!["on".to_owned(), "off".to_owned()]),
4146 },
4147 super::state::SettingsRow {
4148 id: "doubleEscapeAction".to_owned(),
4149 label: "Double-Esc action".to_owned(),
4150 description: Some("tree / fork / none".to_owned()),
4151 current_value: format!("{:?}", settings.get_double_escape_action())
4152 .to_lowercase(),
4153 values: Some(vec![
4154 "tree".to_owned(),
4155 "fork".to_owned(),
4156 "none".to_owned(),
4157 ]),
4158 },
4159 ])
4160 })
4161 }
4162
4163 fn get_config_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
4164 let session = self.read_session();
4165 Box::pin(async move {
4166 let settings = session.lock_settings();
4167 Ok(vec![
4168 super::state::SettingsRow {
4169 id: "quietStartup".to_owned(),
4170 label: "Quiet startup".to_owned(),
4171 description: Some("Suppress logo/header on launch".to_owned()),
4172 current_value: if settings.get_quiet_startup() {
4173 "on".to_owned()
4174 } else {
4175 "off".to_owned()
4176 },
4177 values: Some(vec!["on".to_owned(), "off".to_owned()]),
4178 },
4179 super::state::SettingsRow {
4180 id: "showImages".to_owned(),
4181 label: "Show images".to_owned(),
4182 description: Some("Render inline images in the transcript".to_owned()),
4183 current_value: if settings.get_show_images() {
4184 "on".to_owned()
4185 } else {
4186 "off".to_owned()
4187 },
4188 values: Some(vec!["on".to_owned(), "off".to_owned()]),
4189 },
4190 ])
4191 })
4192 }
4193
4194 fn execute_bash(
4195 &self,
4196 command: &str,
4197 exclude_from_context: bool,
4198 ) -> BoxFuture<'_, Result<(), String>> {
4199 let session = self.read_session();
4200 let command = command.to_owned();
4201 Box::pin(async move {
4202 let opts = ExecuteBashOptions {
4203 exclude_from_context,
4204 ..ExecuteBashOptions::default()
4205 };
4206 session
4207 .execute_bash(command.as_str(), None::<fn(&str)>, opts)
4208 .await
4209 .map(|_| ())
4210 .map_err(|e| e.to_string())
4211 })
4212 }
4213
4214 fn new_session(&self) -> BoxFuture<'_, Result<(), String>> {
4215 let runtime = Arc::clone(&self.runtime);
4216 let host_session = Arc::clone(&self.session);
4217 Box::pin(async move {
4218 runtime
4219 .new_session(NewSessionOptions::default())
4220 .await
4221 .map(|_| ())
4222 .map_err(|err| runtime_err_to_string(&err))?;
4223 if let Ok(mut guard) = host_session.write() {
4224 *guard = runtime.session();
4225 }
4226 Ok(())
4227 })
4228 }
4229
4230 fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>> {
4231 let runtime = Arc::clone(&self.runtime);
4232 let host_session = Arc::clone(&self.session);
4233 let entry_id = entry_id.to_owned();
4234 Box::pin(async move {
4235 runtime
4236 .fork(&entry_id, ForkPosition::Before)
4237 .await
4238 .map(|_| ())
4239 .map_err(|err| runtime_err_to_string(&err))?;
4240 if let Ok(mut guard) = host_session.write() {
4241 *guard = runtime.session();
4242 }
4243 Ok(())
4244 })
4245 }
4246
4247 fn clone(&self) -> BoxFuture<'_, Result<(), String>> {
4248 let runtime = Arc::clone(&self.runtime);
4249 let host_session = Arc::clone(&self.session);
4250 Box::pin(async move {
4251 let leaf = {
4252 let session = runtime.session();
4253 let manager = session.session_manager();
4254 let sm = manager.lock().await;
4255 sm.get_leaf_id().map(str::to_owned)
4256 };
4257 let leaf =
4258 leaf.ok_or_else(|| "Cannot clone session: no current entry selected".to_owned())?;
4259 runtime
4260 .fork(&leaf, ForkPosition::At)
4261 .await
4262 .map(|_| ())
4263 .map_err(|err| runtime_err_to_string(&err))?;
4264 if let Ok(mut guard) = host_session.write() {
4265 *guard = runtime.session();
4266 }
4267 Ok(())
4268 })
4269 }
4270
4271 fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>> {
4272 let runtime = Arc::clone(&self.runtime);
4273 let host_session = Arc::clone(&self.session);
4274 let path = path.to_owned();
4275 Box::pin(async move {
4276 runtime
4277 .switch_session(&path, SwitchSessionOptions::default())
4278 .await
4279 .map(|_| ())
4280 .map_err(|err| runtime_err_to_string(&err))?;
4281 if let Ok(mut guard) = host_session.write() {
4282 *guard = runtime.session();
4283 }
4284 Ok(())
4285 })
4286 }
4287
4288 fn export_html(&self, path: Option<&str>) -> BoxFuture<'_, Result<String, String>> {
4289 let session = self.read_session();
4290 let path = path.map(str::to_owned);
4291 Box::pin(async move {
4292 session
4293 .export_to_html(path.as_deref(), None)
4294 .await
4295 .map_err(|e| e.to_string())
4296 })
4297 }
4298
4299 fn set_session_name(&self, name: &str) -> BoxFuture<'_, Result<(), String>> {
4300 let session = self.read_session();
4301 let name = name.to_owned();
4302 Box::pin(async move {
4303 session
4304 .set_session_name(&name)
4305 .await
4306 .map_err(|e| e.to_string())
4307 })
4308 }
4309
4310 fn logout(&self) -> BoxFuture<'_, Result<(), String>> {
4311 Box::pin(async { Ok(()) })
4312 }
4313
4314 fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>> {
4315 let session = self.read_session();
4316 Box::pin(async move { Ok(session.get_last_assistant_text()) })
4317 }
4318}
4319
4320fn runtime_err_to_string(err: &AgentSessionRuntimeError) -> String {
4322 err.to_string()
4323}
4324
4325fn flatten_tree_nodes(
4326 nodes: &[crate::core::sessions::SessionTreeNode],
4327 depth: usize,
4328 out: &mut Vec<super::state::TreeEntry>,
4329) {
4330 for node in nodes {
4331 let id = node.entry.id().unwrap_or("").to_owned();
4332 let label = node
4333 .label
4334 .clone()
4335 .unwrap_or_else(|| tree_entry_label(&node.entry));
4336 if !id.is_empty() {
4337 out.push(super::state::TreeEntry {
4338 value: id,
4339 label,
4340 depth,
4341 });
4342 }
4343 flatten_tree_nodes(&node.children, depth.saturating_add(1), out);
4344 }
4345}
4346
4347fn tree_entry_label(entry: &crate::core::sessions::SessionEntry) -> String {
4348 use crate::core::sessions::SessionEntry;
4349 match entry {
4350 SessionEntry::Message(message) => {
4351 let text =
4352 crate::core::agent_session::tree::extract_user_message_text_pub(&message.message);
4353 if text.is_empty() {
4354 message.message.role().to_owned()
4355 } else {
4356 text.chars().take(80).collect()
4357 }
4358 }
4359 SessionEntry::Compaction(compaction) => format!(
4360 "compaction: {}",
4361 compaction.summary.chars().take(40).collect::<String>()
4362 ),
4363 SessionEntry::BranchSummary(branch) => format!(
4364 "branch: {}",
4365 branch.summary.chars().take(40).collect::<String>()
4366 ),
4367 SessionEntry::Custom(custom) => format!("custom:{}", custom.custom_type),
4368 SessionEntry::CustomMessage(custom) => {
4369 format!("custom_message:{}", custom.custom_type)
4370 }
4371 SessionEntry::Label(label) => format!("label:{}", label.id),
4372 SessionEntry::SessionInfo(info) => format!("session_info:{}", info.id),
4373 SessionEntry::ThinkingLevelChange(change) => {
4374 format!("thinking:{}", change.thinking_level)
4375 }
4376 SessionEntry::ModelChange(change) => {
4377 format!("model:{}/{}", change.provider, change.model_id)
4378 }
4379 SessionEntry::Unknown(_) => "unknown".to_owned(),
4380 }
4381}
4382
4383fn initial_terminal_size() -> (u16, u16) {
4387 match crossterm::terminal::size() {
4388 Ok((width, height)) => (width.clamp(20, 1024), height.clamp(1, 256)),
4389 Err(_) => (80, 24),
4390 }
4391}
4392
4393fn install_product_panic_emergency_hook<W>(
4394 emergency: Arc<std::sync::atomic::AtomicBool>,
4395 writer: W,
4396) -> Arc<dyn Fn() + Send + Sync>
4397where
4398 W: Write + Send + 'static,
4399{
4400 let writer = std::sync::Mutex::new(writer);
4401 let restore: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
4402 if let Ok(mut writer) = writer.lock() {
4403 let _ = write_emergency_restore_bytes(&mut *writer);
4404 }
4405 });
4406 install_panic_emergency_hook(emergency, Arc::clone(&restore));
4407 restore
4408}
4409
4410pub async fn run_interactive_mode(
4428 runtime: Arc<AgentSessionRuntime>,
4429 options: InteractiveRuntimeOptions,
4430) -> Result<u8, String> {
4431 use std::io::stdout;
4432 if !stdout().is_terminal() {
4433 return Err("interactive mode requires a tty".to_owned());
4434 }
4435
4436 let size = initial_terminal_size();
4439 let mut guard = TerminalGuard::new(stdout());
4440 guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4441 let _panic_restore = install_product_panic_emergency_hook(guard.emergency_flag(), stdout());
4442 let enable_kitty = !cfg!(windows);
4443 guard
4444 .activate(enable_kitty)
4445 .map_err(|e| format!("terminal activation failed: {e}"))?;
4446
4447 let stdout_writer = stdout();
4452 let viewport_height = options.viewport_height.max(1).min(size.1);
4453 let tui = Tui::new(
4454 stdout_writer,
4455 ratatui::layout::Size::new(size.0, size.1),
4456 ratatui::layout::Position::ORIGIN,
4457 viewport_height,
4458 options.caps.clone(),
4459 )
4460 .map_err(|e| format!("tui initialization failed: {e}"))?;
4461
4462 let input = TerminalInput::spawn();
4464
4465 let host = AgentSessionHost::new(Arc::clone(&runtime));
4469 let host_arc = Arc::new(host);
4470
4471 let _ = host_arc
4475 .session()
4476 .bind_extensions(crate::core::agent_session::ExtensionBindings {
4477 mode: Some(crate::core::agent_session::ExtensionMode::Tui),
4478 ..Default::default()
4479 })
4480 .await;
4481
4482 {
4486 let host_for_rebind = Arc::clone(&host_arc);
4487 runtime.set_rebind_session(Some(Arc::new(move |_session| {
4488 let host_for_rebind = Arc::clone(&host_for_rebind);
4489 Box::pin(async move {
4490 host_for_rebind.refresh();
4491 let _ = host_for_rebind
4492 .session()
4493 .bind_extensions(crate::core::agent_session::ExtensionBindings {
4494 mode: Some(crate::core::agent_session::ExtensionMode::Tui),
4495 ..Default::default()
4496 })
4497 .await;
4498 })
4499 })));
4500 }
4501
4502 let mut rt = InteractiveRuntime::new(tui, input, host_arc, &options);
4503
4504 let exit = loop {
4507 let exit = rt.run().await;
4508 guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4511 let exit = exit.map_err(|e| format!("runtime loop: {e}"))?;
4512 match exit {
4513 InteractiveExit::Suspend => {
4514 rt.close_selector_for_suspend();
4516 guard
4519 .suspend()
4520 .map_err(|e| format!("terminal suspend failed: {e}"))?;
4521 let size = initial_terminal_size();
4522 guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4523 guard
4524 .resume(enable_kitty)
4525 .map_err(|e| format!("terminal resume failed: {e}"))?;
4526 let _ = rt
4529 .step_ui(UiEvent::Resize {
4530 width: size.0,
4531 height: size.1,
4532 })
4533 .await;
4534 guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4535 rt.rebind_session_channels().await;
4539 }
4540 InteractiveExit::ExternalEditor => {
4541 run_external_editor_handoff(&mut rt, &mut guard, enable_kitty).await?;
4542 }
4543 other => break other,
4544 }
4545 };
4546
4547 drop(rt);
4549 runtime.set_rebind_session(None);
4550
4551 let code = match exit {
4553 InteractiveExit::Clean
4554 | InteractiveExit::SessionEnded
4555 | InteractiveExit::Suspend
4556 | InteractiveExit::ExternalEditor => 0u8,
4557 InteractiveExit::IoFailure | InteractiveExit::DrawDeadlock => 1u8,
4558 };
4559
4560 guard.restore();
4561 Ok(code)
4562}
4563
4564async fn run_external_editor_handoff<W, G, S>(
4567 rt: &mut InteractiveRuntime<W, S>,
4568 guard: &mut TerminalGuard<G>,
4569 enable_kitty: bool,
4570) -> Result<(), String>
4571where
4572 W: Write,
4573 G: Write,
4574 S: SessionHost,
4575{
4576 let initial = rt.editor.get_text();
4577 let editor_command = rt.session.external_editor_command();
4578 rt.input
4579 .pause()
4580 .await
4581 .map_err(|e| format!("pause terminal input for editor: {e}"))?;
4582 guard.restore();
4583
4584 let cancel = CancellationToken::new();
4585 let cancel_on_shutdown = cancel.clone();
4586 let shutdown = Arc::clone(&rt.shutdown);
4587 let watcher = tokio::spawn(async move {
4588 shutdown.notified().await;
4589 cancel_on_shutdown.cancel();
4590 });
4591 let edited = edit_text_in_external_editor(&editor_command, &initial, &cancel)
4592 .await
4593 .map_err(|error| error.to_string());
4594 watcher.abort();
4595
4596 guard
4597 .resume(enable_kitty)
4598 .map_err(|e| format!("terminal resume after editor failed: {e}"))?;
4599 rt.input
4600 .resume(Vec::new())
4601 .await
4602 .map_err(|e| format!("resume terminal input after editor: {e}"))?;
4603 rt.exited = false;
4604 rt.exit_kind = InteractiveExit::Clean;
4605 match edited {
4606 Ok(EditOutcome::Changed(text)) => {
4607 rt.editor.set_text(&text);
4608 rt.view.editor.text = text;
4609 }
4610 Ok(EditOutcome::Unchanged | EditOutcome::Aborted) => {}
4611 Err(error) => rt.last_error = Some(error),
4612 }
4613 let size = initial_terminal_size();
4614 guard.set_viewport_bottom_row(size.1.saturating_sub(1));
4615 let _ = rt
4616 .step_ui(UiEvent::Resize {
4617 width: size.0,
4618 height: size.1,
4619 })
4620 .await;
4621 guard.set_viewport_bottom_row(rt.viewport_bottom_row());
4622 Ok(())
4623}
4624
4625trait AgentSessionSubscribeExt {
4629 fn subscribe_arc_listener(
4630 &self,
4631 listener: AgentSessionEventListener,
4632 ) -> Box<dyn FnOnce() + Send + Sync>;
4633}
4634
4635impl AgentSessionSubscribeExt for AgentSession {
4636 fn subscribe_arc_listener(
4637 &self,
4638 listener: AgentSessionEventListener,
4639 ) -> Box<dyn FnOnce() + Send + Sync> {
4640 let unsubscribe = self.subscribe(move |event: &AgentSessionEvent| {
4641 listener(event);
4642 });
4643 Box::new(unsubscribe)
4644 }
4645}
4646
4647async fn recv_extension_event(
4648 receiver: &mut Option<tokio::sync::broadcast::Receiver<ExtensionUiEvent>>,
4649) -> Option<ExtensionUiEvent> {
4650 match receiver {
4651 Some(receiver) => loop {
4652 match receiver.recv().await {
4653 Ok(event) => return Some(event),
4654 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
4655 Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
4656 }
4657 },
4658 None => std::future::pending().await,
4659 }
4660}
4661
4662async fn recv_extension_request(
4663 receiver: &mut Option<mpsc::Receiver<HostUiRequest>>,
4664) -> Option<HostUiRequest> {
4665 match receiver {
4666 Some(receiver) => receiver.recv().await,
4667 None => std::future::pending().await,
4668 }
4669}
4670
4671async fn wait_extension_deadline(deadline: Option<Instant>) {
4672 match deadline {
4673 Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
4674 None => std::future::pending().await,
4675 }
4676}
4677
4678fn dialog_timeout(request: &HostUiRequest) -> Option<Duration> {
4679 let timeout_ms = match request {
4680 HostUiRequest::Select { request, .. } => request.options_meta.timeout_ms,
4681 HostUiRequest::Confirm { request, .. } => request.options_meta.timeout_ms,
4682 HostUiRequest::Input { request, .. } => request.options_meta.timeout_ms,
4683 HostUiRequest::Editor { .. } => None,
4684 }?;
4685 Some(Duration::from_millis(timeout_ms))
4686}
4687
4688fn extension_dialog_title(request: &HostUiRequest) -> String {
4689 match request {
4690 HostUiRequest::Select { request, .. } => request.title.clone(),
4691 HostUiRequest::Confirm { request, .. } => {
4692 format!("{}\n{}", request.title, request.message)
4693 }
4694 HostUiRequest::Input { request, .. } => request.title.clone(),
4695 HostUiRequest::Editor { request, .. } => request.title.clone(),
4696 }
4697}
4698
4699const RESERVED_EXTENSION_SHORTCUTS: &[&str] = &[
4700 "escape",
4701 "ctrl+c",
4702 "ctrl+d",
4703 "ctrl+z",
4704 "shift+tab",
4705 "ctrl+p",
4706 "shift+ctrl+p",
4707 "ctrl+l",
4708 "ctrl+o",
4709 "ctrl+t",
4710 "ctrl+g",
4711 "ctrl+x",
4712 "alt+enter",
4713 "enter",
4714 "ctrl+k",
4715];
4716
4717fn build_effective_extension_shortcuts(
4718 registrations: &[pi_ext::adapters::ShortcutRegistration],
4719) -> Vec<EffectiveExtensionShortcut> {
4720 let reserved = RESERVED_EXTENSION_SHORTCUTS
4721 .iter()
4722 .filter_map(|key| parse_key_id(key).ok())
4723 .map(|key| key.canonical_id())
4724 .collect::<Vec<_>>();
4725 let mut effective = Vec::<EffectiveExtensionShortcut>::new();
4726 for registration in registrations {
4727 let Ok(parsed) = parse_key_id(®istration.key) else {
4728 continue;
4729 };
4730 let key = parsed.canonical_id().as_str().to_owned();
4731 if reserved.iter().any(|reserved| reserved.as_str() == key) {
4732 continue;
4733 }
4734 effective.retain(|shortcut| shortcut.key != key);
4735 effective.push(EffectiveExtensionShortcut {
4736 key,
4737 dispatch_key: registration.key.clone(),
4738 parsed,
4739 description: registration.description.clone(),
4740 source: registration.extension_path.clone(),
4741 });
4742 }
4743 effective
4744}
4745
4746fn shortcut_hints(shortcuts: &[EffectiveExtensionShortcut]) -> Vec<super::state::ShortcutHint> {
4747 shortcuts
4748 .iter()
4749 .map(|shortcut| super::state::ShortcutHint {
4750 key: shortcut.key.clone(),
4751 action: shortcut
4752 .description
4753 .clone()
4754 .or_else(|| shortcut.source.clone())
4755 .unwrap_or_else(|| "Extension shortcut".to_owned()),
4756 })
4757 .collect()
4758}
4759
4760fn ui_event_wire(event: &UiEvent) -> UiEventWire {
4761 match event {
4762 UiEvent::Key(key) => {
4763 let (code, modifiers) = pi_tui::keys::normalize_event(key)
4764 .unwrap_or_else(|| (format!("{:?}", key.code), key.modifiers));
4765 UiEventWire::Key {
4766 code,
4767 modifiers: KeyModifiersWire {
4768 shift: modifiers
4769 .contains(crossterm::event::KeyModifiers::SHIFT)
4770 .then_some(true),
4771 alt: modifiers
4772 .contains(crossterm::event::KeyModifiers::ALT)
4773 .then_some(true),
4774 ctrl: modifiers
4775 .contains(crossterm::event::KeyModifiers::CONTROL)
4776 .then_some(true),
4777 super_key: modifiers
4778 .contains(crossterm::event::KeyModifiers::SUPER)
4779 .then_some(true),
4780 },
4781 kind: match key.kind {
4782 crossterm::event::KeyEventKind::Press => KeyEventKindWire::Press,
4783 crossterm::event::KeyEventKind::Repeat => KeyEventKindWire::Repeat,
4784 crossterm::event::KeyEventKind::Release => KeyEventKindWire::Release,
4785 },
4786 }
4787 }
4788 UiEvent::Paste(text) => UiEventWire::Paste { text: text.clone() },
4789 UiEvent::FocusGained => UiEventWire::FocusGained,
4790 UiEvent::FocusLost => UiEventWire::FocusLost,
4791 UiEvent::Resize { width, height } => UiEventWire::Resize {
4792 width: *width,
4793 height: *height,
4794 },
4795 }
4796}
4797
4798fn default_extension_dialog_response(request: &HostUiRequest) -> HostUiResponse {
4799 match request {
4800 HostUiRequest::Select { id, .. } => HostUiResponse::Select {
4801 id: *id,
4802 value: None,
4803 },
4804 HostUiRequest::Confirm { id, .. } => HostUiResponse::Confirm {
4805 id: *id,
4806 confirmed: false,
4807 },
4808 HostUiRequest::Input { id, .. } => HostUiResponse::Input {
4809 id: *id,
4810 value: None,
4811 },
4812 HostUiRequest::Editor { id, .. } => HostUiResponse::Editor {
4813 id: *id,
4814 value: None,
4815 },
4816 }
4817}
4818
4819fn encode_terminal_input(event: &UiEvent) -> Option<String> {
4820 match event {
4821 UiEvent::Paste(text) => Some(text.clone()),
4822 UiEvent::Key(key) => encode_key_event(key),
4823 UiEvent::FocusGained | UiEvent::FocusLost | UiEvent::Resize { .. } => None,
4824 }
4825}
4826
4827fn decode_terminal_input(data: String) -> UiEvent {
4828 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4829 let key = match data.as_str() {
4830 "\r" | "\n" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
4831 "\t" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
4832 "\u{7f}" | "\u{8}" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
4833 "\u{1b}" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
4834 "\u{1b}[A" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
4835 "\u{1b}[B" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
4836 "\u{1b}[C" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)),
4837 "\u{1b}[D" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)),
4838 "\u{1b}[H" => Some(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
4839 "\u{1b}[F" => Some(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
4840 "\u{1b}[3~" => Some(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
4841 "\u{1b}[Z" => Some(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
4842 _ if data.starts_with('\u{1b}') && data.chars().count() == 2 => data
4843 .chars()
4844 .nth(1)
4845 .map(|character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)),
4846 _ => {
4847 let mut characters = data.chars();
4848 match (characters.next(), characters.next()) {
4849 (Some(character), None) if (character as u32) < 0x20 => {
4850 let letter = char::from((character as u8) | 0x60);
4851 Some(KeyEvent::new(KeyCode::Char(letter), KeyModifiers::CONTROL))
4852 }
4853 (Some(character), None) => {
4854 Some(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE))
4855 }
4856 _ => None,
4857 }
4858 }
4859 };
4860 key.map_or(UiEvent::Paste(data), UiEvent::Key)
4861}
4862
4863fn is_global_app_binding(event: &UiEvent) -> bool {
4864 let UiEvent::Key(key) = event else {
4865 return false;
4866 };
4867 if key.kind == crossterm::event::KeyEventKind::Release {
4868 return false;
4869 }
4870 let modifiers = key.modifiers;
4871 match key.code {
4872 KeyCode::Esc => true,
4873 KeyCode::BackTab | KeyCode::Tab => modifiers == KeyModifiers::SHIFT,
4874 KeyCode::Enter | KeyCode::Up => modifiers == KeyModifiers::ALT,
4875 KeyCode::Char(character) => {
4876 let character = character.to_ascii_lowercase();
4877 (modifiers == KeyModifiers::CONTROL
4878 && matches!(
4879 character,
4880 'c' | 'd' | 'z' | 'p' | 'l' | 'o' | 't' | 'g' | 'x' | 'r' | 'b' | 'f' | 'n'
4881 ))
4882 || (character == 'p'
4883 && modifiers.contains(KeyModifiers::CONTROL)
4884 && modifiers.contains(KeyModifiers::SHIFT))
4885 || (character == 'v'
4886 && matches!(modifiers, KeyModifiers::CONTROL | KeyModifiers::ALT))
4887 }
4888 _ => false,
4889 }
4890}
4891
4892#[cfg(test)]
4893mod tests {
4894 use std::path::PathBuf;
4895 use std::sync::Arc;
4896 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
4897 use std::time::Duration;
4898
4899 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4900 use futures::future::BoxFuture;
4901 use futures::stream::{self, StreamExt};
4902 use pi_ai::{
4903 AssistantContent, AssistantMessage, AssistantMessageEvent, Context, DoneReason, Model,
4904 ModelCost, ModelInput, Provider, ProviderError, StopReason, StreamOptions, TextContent,
4905 };
4906 use pi_tui::component::UiEvent;
4907 use pi_tui::terminal::caps::TerminalCapabilities;
4908 use pi_tui::terminal::writer::Tui;
4909 use ratatui::layout::{Position, Size};
4910 use tokio::sync::{Mutex, mpsc, watch};
4911
4912 use super::*;
4913 use crate::core::agent_session::events::AgentSessionEvent;
4914 use crate::core::agent_session::{AgentSession, AgentSessionConfig};
4915 use crate::core::agent_session_runtime::{
4916 AgentSessionRuntimeServices, CreateAgentSessionRuntimeFactory,
4917 CreateAgentSessionRuntimeOptions, CreateAgentSessionRuntimeResult,
4918 };
4919 use crate::modes::interactive::state::SelectorKind;
4920
4921 #[derive(Default)]
4923 struct ActionLog {
4924 prompts: Mutex<Vec<String>>,
4925 prompt_images: Mutex<Vec<Vec<ImageContent>>>,
4926 bash_started: Notify,
4927 bash_release: Notify,
4928 prompt_behaviors: Mutex<Vec<Option<StreamingBehavior>>>,
4929 aborts: Mutex<u32>,
4930 compacts: Mutex<Vec<Option<String>>>,
4931 cycles: Mutex<u32>,
4932 reloads: Mutex<u32>,
4933 bashes: Mutex<Vec<(String, bool)>>,
4934 new_sessions: Mutex<u32>,
4935 forks: Mutex<Vec<String>>,
4936 clones: Mutex<u32>,
4937 switches: Mutex<Vec<String>>,
4938 logouts: Mutex<u32>,
4939 follows: Mutex<Vec<String>>,
4940 steers: Mutex<Vec<String>>,
4941 last_text: Mutex<Option<String>>,
4942 }
4943
4944 struct FakeHost {
4945 log: Arc<ActionLog>,
4946 partial_tx: watch::Sender<Option<Arc<AssistantMessage>>>,
4947 snapshot: Arc<std::sync::Mutex<SessionSnapshot>>,
4948 event_senders: Arc<std::sync::Mutex<Vec<mpsc::UnboundedSender<AgentSessionEvent>>>>,
4949 stream_chunks: Arc<AtomicUsize>,
4950 }
4951
4952 type GateEvents = Vec<Result<AssistantMessageEvent, ProviderError>>;
4953 type GateResponses = Arc<std::sync::Mutex<Vec<GateEvents>>>;
4954
4955 #[derive(Clone)]
4956 struct GateProvider {
4957 hold: Arc<AtomicBool>,
4958 started: Arc<AtomicBool>,
4959 responses: GateResponses,
4960 }
4961
4962 impl Provider for GateProvider {
4963 fn stream(
4964 &self,
4965 _model: &Model,
4966 _context: Context,
4967 options: StreamOptions,
4968 ) -> futures::stream::BoxStream<'static, Result<AssistantMessageEvent, ProviderError>>
4969 {
4970 let hold = Arc::clone(&self.hold);
4971 let started = Arc::clone(&self.started);
4972 let responses = Arc::clone(&self.responses);
4973 let signal = options.signal.clone();
4974 let events = responses
4975 .lock()
4976 .unwrap_or_else(std::sync::PoisonError::into_inner)
4977 .pop()
4978 .unwrap_or_default();
4979 stream::once(async move {
4980 started.store(true, Ordering::SeqCst);
4981 loop {
4984 if !hold.load(Ordering::SeqCst) {
4985 break;
4986 }
4987 if signal
4988 .as_ref()
4989 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
4990 {
4991 break;
4992 }
4993 tokio::task::yield_now().await;
4994 }
4995 stream::iter(events)
4996 })
4997 .flatten()
4998 .boxed()
4999 }
5000 }
5001
5002 struct UnusedReplacementFactory;
5003
5004 impl CreateAgentSessionRuntimeFactory for UnusedReplacementFactory {
5005 fn create(
5006 &self,
5007 _options: CreateAgentSessionRuntimeOptions,
5008 ) -> BoxFuture<'_, Result<CreateAgentSessionRuntimeResult, AgentSessionRuntimeError>>
5009 {
5010 Box::pin(async {
5011 Err(AgentSessionRuntimeError::Factory(
5012 "replacement factory unused by admission projection test".to_owned(),
5013 ))
5014 })
5015 }
5016 }
5017
5018 struct AdmissionFixture {
5019 runtime: InteractiveRuntime<SharedWriter, AgentSessionHost>,
5020 session_runtime: Arc<AgentSessionRuntime>,
5021 hold: Arc<AtomicBool>,
5022 started: Arc<AtomicBool>,
5023 }
5024
5025 fn make_admission_fixture() -> Result<AdmissionFixture, String> {
5026 let model = Model {
5027 id: "m".to_owned(),
5028 name: "m".to_owned(),
5029 api: "test-api".to_owned(),
5030 provider: "test-provider".to_owned(),
5031 base_url: String::new(),
5032 reasoning: false,
5033 thinking_level_map: None,
5034 input: vec![ModelInput::Text],
5035 cost: ModelCost::default(),
5036 context_window: 8_192,
5037 max_tokens: 1_024,
5038 headers: None,
5039 compat: None,
5040 extra: std::collections::BTreeMap::new(),
5041 };
5042 let mut done =
5043 AssistantMessage::new("test-api", "test-provider", "m", pi_agent::now_millis());
5044 done.content
5045 .push(AssistantContent::Text(TextContent::new("ok")));
5046 done.stop_reason = StopReason::Stop;
5047 let hold = Arc::new(AtomicBool::new(true));
5048 let started = Arc::new(AtomicBool::new(false));
5049 let provider = Arc::new(GateProvider {
5051 hold: Arc::clone(&hold),
5052 started: Arc::clone(&started),
5053 responses: Arc::new(std::sync::Mutex::new(vec![
5054 vec![
5055 Ok(AssistantMessageEvent::Start {
5056 partial: AssistantMessage::new(
5057 "test-api",
5058 "test-provider",
5059 "m",
5060 pi_agent::now_millis(),
5061 ),
5062 }),
5063 Ok(AssistantMessageEvent::Done {
5064 reason: DoneReason::Stop,
5065 message: done.clone(),
5066 }),
5067 ],
5068 vec![
5069 Ok(AssistantMessageEvent::Start {
5070 partial: AssistantMessage::new(
5071 "test-api",
5072 "test-provider",
5073 "m",
5074 pi_agent::now_millis(),
5075 ),
5076 }),
5077 Ok(AssistantMessageEvent::Done {
5078 reason: DoneReason::Stop,
5079 message: done,
5080 }),
5081 ],
5082 ])),
5083 });
5084 let session = AgentSession::new(
5085 AgentSessionConfig::test_config(provider, model)
5086 .map_err(|error| format!("session config: {error}"))?,
5087 )
5088 .map_err(|error| format!("session create: {error}"))?;
5089 let session_runtime = Arc::new(AgentSessionRuntime::new(
5090 session,
5091 AgentSessionRuntimeServices {
5092 cwd: PathBuf::from("."),
5093 agent_dir: PathBuf::from("."),
5094 },
5095 Arc::new(UnusedReplacementFactory),
5096 Vec::new(),
5097 None,
5098 ));
5099 let host = AgentSessionHost::new(Arc::clone(&session_runtime));
5100 let tui = Tui::new(
5101 SharedWriter::new(),
5102 Size::new(80, 24),
5103 Position::ORIGIN,
5104 8,
5105 TerminalCapabilities::default(),
5106 )
5107 .map_err(|error| format!("tui construction: {error}"))?;
5108 let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5109 let input = TerminalInput::mock(rx);
5110 let options = InteractiveRuntimeOptions {
5111 size: (80, 24),
5112 ..InteractiveRuntimeOptions::default()
5113 };
5114 Ok(AdmissionFixture {
5115 runtime: InteractiveRuntime::new(tui, input, Arc::new(host), &options),
5116 session_runtime,
5117 hold,
5118 started,
5119 })
5120 }
5121
5122 impl FakeHost {
5123 fn new() -> (Self, Arc<ActionLog>) {
5124 let log = Arc::new(ActionLog::default());
5125 let (partial_tx, _partial_rx) = watch::channel(None);
5126 let host = Self {
5127 log: Arc::clone(&log),
5128 partial_tx,
5129 snapshot: Arc::new(std::sync::Mutex::new(SessionSnapshot::default())),
5130 event_senders: Arc::new(std::sync::Mutex::new(Vec::new())),
5131 stream_chunks: Arc::new(AtomicUsize::new(0)),
5132 };
5133 (host, log)
5134 }
5135
5136 fn set_stream_chunks(&self, chunks: usize) {
5137 self.stream_chunks.store(chunks, Ordering::SeqCst);
5138 }
5139 }
5140
5141 impl SessionHost for FakeHost {
5142 fn snapshot(&self) -> SessionSnapshot {
5143 self.snapshot
5144 .lock()
5145 .unwrap_or_else(std::sync::PoisonError::into_inner)
5146 .clone()
5147 }
5148
5149 fn subscribe(&self) -> EventSubscription {
5150 let (tx, rx) = mpsc::unbounded_channel();
5151 self.event_senders
5152 .lock()
5153 .unwrap_or_else(std::sync::PoisonError::into_inner)
5154 .push(tx);
5155 EventSubscription {
5156 rx,
5157 unsubscribe: None,
5158 }
5159 }
5160
5161 fn partial_rx(&self) -> watch::Receiver<Option<Arc<AssistantMessage>>> {
5162 self.partial_tx.subscribe()
5163 }
5164
5165 fn prompt(&self, text: &str, opts: PromptOptions) -> BoxFuture<'_, Result<(), String>> {
5166 let log = Arc::clone(&self.log);
5167 let owned = text.to_owned();
5168 let partial_tx = self.partial_tx.clone();
5169 let snapshot = Arc::clone(&self.snapshot);
5170 let stream_chunks = Arc::clone(&self.stream_chunks);
5171 Box::pin(async move {
5172 log.prompts.lock().await.push(owned);
5173 log.prompt_images.lock().await.push(opts.images.clone());
5174 log.prompt_behaviors
5175 .lock()
5176 .await
5177 .push(opts.streaming_behavior);
5178 if opts.streaming_behavior.is_some() {
5179 return Ok(());
5180 }
5181
5182 let chunks = stream_chunks.load(Ordering::SeqCst);
5183 if chunks == 0 {
5184 return Ok(());
5185 }
5186 {
5187 let mut state = snapshot
5188 .lock()
5189 .unwrap_or_else(std::sync::PoisonError::into_inner);
5190 state.admission_active = true;
5191 state.activity = SessionActivity::Streaming;
5192 }
5193 for index in 0..chunks {
5194 let text = if index + 1 == chunks {
5195 "<<Done>>".to_owned()
5196 } else {
5197 format!("stream-chunk-{index:02}")
5198 };
5199 let mut message = AssistantMessage::new("test", "test", "test", 0);
5200 message
5201 .content
5202 .push(AssistantContent::Text(TextContent::new(text)));
5203 partial_tx.send_replace(Some(Arc::new(message)));
5204 tokio::time::sleep(Duration::from_millis(2)).await;
5205 }
5206 {
5207 let mut state = snapshot
5208 .lock()
5209 .unwrap_or_else(std::sync::PoisonError::into_inner);
5210 state.admission_active = false;
5211 state.activity = SessionActivity::Idle;
5212 }
5213 Ok(())
5214 })
5215 }
5216
5217 fn steer(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
5218 let log = Arc::clone(&self.log);
5219 let owned = text.to_owned();
5220 Box::pin(async move {
5221 log.steers.lock().await.push(owned);
5222 Ok(())
5223 })
5224 }
5225
5226 fn follow_up(&self, text: &str) -> BoxFuture<'_, Result<(), String>> {
5227 let log = Arc::clone(&self.log);
5228 let owned = text.to_owned();
5229 Box::pin(async move {
5230 log.follows.lock().await.push(owned);
5231 Ok(())
5232 })
5233 }
5234
5235 fn abort(&self) -> BoxFuture<'static, Result<(), String>> {
5236 let log = Arc::clone(&self.log);
5237 Box::pin(async move {
5238 *log.aborts.lock().await += 1;
5239 log.bash_release.notify_one();
5240 Ok(())
5241 })
5242 }
5243
5244 fn compact(&self, instructions: Option<&str>) -> BoxFuture<'_, Result<(), String>> {
5245 let log = Arc::clone(&self.log);
5246 let instructions = instructions.map(str::to_owned);
5247 Box::pin(async move {
5248 log.compacts.lock().await.push(instructions);
5249 Ok(())
5250 })
5251 }
5252
5253 fn cycle_thinking_level(&self) -> BoxFuture<'_, Result<(), String>> {
5254 let log = Arc::clone(&self.log);
5255 Box::pin(async move {
5256 *log.cycles.lock().await += 1;
5257 Ok(())
5258 })
5259 }
5260
5261 fn cycle_model(&self, _forward: bool) -> BoxFuture<'_, Result<(), String>> {
5262 let log = Arc::clone(&self.log);
5263 Box::pin(async move {
5264 *log.cycles.lock().await += 1;
5265 Ok(())
5266 })
5267 }
5268
5269 fn reload(&self) -> BoxFuture<'_, Result<(), String>> {
5270 let log = Arc::clone(&self.log);
5271 Box::pin(async move {
5272 *log.reloads.lock().await += 1;
5273 Ok(())
5274 })
5275 }
5276
5277 fn execute_bash(&self, command: &str, exclude: bool) -> BoxFuture<'_, Result<(), String>> {
5278 let log = Arc::clone(&self.log);
5279 let owned = command.to_owned();
5280 Box::pin(async move {
5281 let should_wait = owned == "hang";
5282 log.bashes.lock().await.push((owned, exclude));
5283 if should_wait {
5284 log.bash_started.notify_one();
5285 log.bash_release.notified().await;
5286 }
5287 Ok(())
5288 })
5289 }
5290
5291 fn new_session(&self) -> BoxFuture<'_, Result<(), String>> {
5292 let log = Arc::clone(&self.log);
5293 Box::pin(async move {
5294 *log.new_sessions.lock().await += 1;
5295 Ok(())
5296 })
5297 }
5298
5299 fn fork(&self, entry_id: &str) -> BoxFuture<'_, Result<(), String>> {
5300 let log = Arc::clone(&self.log);
5301 let owned = entry_id.to_owned();
5302 Box::pin(async move {
5303 log.forks.lock().await.push(owned);
5304 Ok(())
5305 })
5306 }
5307
5308 fn clone(&self) -> BoxFuture<'_, Result<(), String>> {
5309 let log = Arc::clone(&self.log);
5310 Box::pin(async move {
5311 *log.clones.lock().await += 1;
5312 Ok(())
5313 })
5314 }
5315
5316 fn switch_session(&self, path: &str) -> BoxFuture<'_, Result<(), String>> {
5317 let log = Arc::clone(&self.log);
5318 let owned = path.to_owned();
5319 Box::pin(async move {
5320 log.switches.lock().await.push(owned);
5321 Ok(())
5322 })
5323 }
5324
5325 fn export_html(&self, _path: Option<&str>) -> BoxFuture<'_, Result<String, String>> {
5326 Box::pin(async { Ok("<html></html>".to_owned()) })
5327 }
5328
5329 fn set_session_name(&self, _name: &str) -> BoxFuture<'_, Result<(), String>> {
5330 Box::pin(async { Ok(()) })
5331 }
5332
5333 fn logout(&self) -> BoxFuture<'_, Result<(), String>> {
5334 let log = Arc::clone(&self.log);
5335 Box::pin(async move {
5336 *log.logouts.lock().await += 1;
5337 Ok(())
5338 })
5339 }
5340
5341 fn messages(&self) -> Vec<pi_agent::AgentMessage> {
5342 Vec::new()
5343 }
5344
5345 fn get_model_entries(
5346 &self,
5347 ) -> BoxFuture<'_, Result<Vec<super::state::ModelSelectorEntry>, String>> {
5348 Box::pin(async {
5349 Ok(vec![super::state::ModelSelectorEntry {
5350 value: "test/model".to_owned(),
5351 label: "Test Model".to_owned(),
5352 description: None,
5353 }])
5354 })
5355 }
5356
5357 fn get_session_entries(
5358 &self,
5359 ) -> BoxFuture<'_, Result<Vec<super::state::SessionPickerEntry>, String>> {
5360 Box::pin(async {
5361 Ok(vec![super::state::SessionPickerEntry {
5362 value: "/tmp/sess.jsonl".to_owned(),
5363 label: "fixture session".to_owned(),
5364 description: None,
5365 }])
5366 })
5367 }
5368
5369 fn get_tree_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
5370 Box::pin(async {
5371 Ok(vec![super::state::TreeEntry {
5372 value: "root".to_owned(),
5373 label: "root".to_owned(),
5374 depth: 0,
5375 }])
5376 })
5377 }
5378
5379 fn get_fork_entries(&self) -> BoxFuture<'_, Result<Vec<super::state::TreeEntry>, String>> {
5380 Box::pin(async {
5381 Ok(vec![super::state::TreeEntry {
5382 value: "user-1".to_owned(),
5383 label: "hello".to_owned(),
5384 depth: 0,
5385 }])
5386 })
5387 }
5388
5389 fn get_trust_entries(
5390 &self,
5391 ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5392 Box::pin(async {
5393 Ok(vec![super::state::SettingsRow {
5394 id: "defaultProjectTrust".to_owned(),
5395 label: "Default project trust".to_owned(),
5396 description: None,
5397 current_value: "ask".to_owned(),
5398 values: Some(vec![
5399 "ask".to_owned(),
5400 "always".to_owned(),
5401 "never".to_owned(),
5402 ]),
5403 }])
5404 })
5405 }
5406
5407 fn get_auth_entries(
5408 &self,
5409 ) -> BoxFuture<'_, Result<Vec<super::state::AuthSelectorEntry>, String>> {
5410 Box::pin(async {
5411 Ok(vec![super::state::AuthSelectorEntry {
5412 value: "anthropic".to_owned(),
5413 label: "Anthropic".to_owned(),
5414 description: Some("configured".to_owned()),
5415 }])
5416 })
5417 }
5418
5419 fn get_scoped_models_entries(
5420 &self,
5421 ) -> BoxFuture<
5422 '_,
5423 Result<
5424 (
5425 Vec<super::state::ModelSelectorEntry>,
5426 std::collections::BTreeMap<String, bool>,
5427 ),
5428 String,
5429 >,
5430 > {
5431 Box::pin(async {
5432 let mut enabled = std::collections::BTreeMap::new();
5433 enabled.insert("test/model".to_owned(), true);
5434 Ok((
5435 vec![super::state::ModelSelectorEntry {
5436 value: "test/model".to_owned(),
5437 label: "Test Model".to_owned(),
5438 description: None,
5439 }],
5440 enabled,
5441 ))
5442 })
5443 }
5444
5445 fn get_settings_entries(
5446 &self,
5447 ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5448 Box::pin(async {
5449 Ok(vec![super::state::SettingsRow {
5450 id: "theme".to_owned(),
5451 label: "Theme".to_owned(),
5452 description: None,
5453 current_value: "dark".to_owned(),
5454 values: Some(vec!["dark".to_owned(), "light".to_owned()]),
5455 }])
5456 })
5457 }
5458
5459 fn get_config_entries(
5460 &self,
5461 ) -> BoxFuture<'_, Result<Vec<super::state::SettingsRow>, String>> {
5462 Box::pin(async {
5463 Ok(vec![super::state::SettingsRow {
5464 id: "quietStartup".to_owned(),
5465 label: "Quiet startup".to_owned(),
5466 description: None,
5467 current_value: "off".to_owned(),
5468 values: Some(vec!["on".to_owned(), "off".to_owned()]),
5469 }])
5470 })
5471 }
5472
5473 fn last_assistant_text(&self) -> BoxFuture<'_, Result<Option<String>, String>> {
5474 let log = Arc::clone(&self.log);
5475 Box::pin(async move {
5476 let t = log.last_text.lock().await.clone();
5477 Ok(t)
5478 })
5479 }
5480 }
5481
5482 fn key(code: KeyCode, mods: KeyModifiers) -> UiEvent {
5483 UiEvent::Key(KeyEvent::new(code, mods))
5484 }
5485
5486 fn try_make_runtime()
5487 -> Result<(InteractiveRuntime<SharedWriter, FakeHost>, Arc<ActionLog>), String> {
5488 let writer = SharedWriter::new();
5489 let caps = TerminalCapabilities::default();
5490 let tui = Tui::new(writer, Size::new(80, 24), Position::ORIGIN, 8, caps)
5491 .map_err(|error| format!("tui construction: {error}"))?;
5492 let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5493 let input = TerminalInput::mock(rx);
5494 let (host, log) = FakeHost::new();
5495 let options = InteractiveRuntimeOptions {
5496 size: (80, 24),
5497 ..InteractiveRuntimeOptions::default()
5498 };
5499 let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5500 let _ = rt.paint_now();
5501 Ok((rt, log))
5502 }
5503
5504 #[test]
5505 fn runtime_options_control_hardware_cursor_visibility() -> Result<(), String> {
5506 for (enabled, expected, unexpected) in [
5507 (true, b"\x1b[?25h".as_slice(), b"\x1b[?25l".as_slice()),
5508 (false, b"\x1b[?25l".as_slice(), b"\x1b[?25h".as_slice()),
5509 ] {
5510 let writer = SharedWriter::new();
5511 let captured = writer.clone();
5512 let tui = Tui::new(
5513 writer,
5514 Size::new(80, 24),
5515 Position::ORIGIN,
5516 8,
5517 TerminalCapabilities::default(),
5518 )
5519 .map_err(|error| format!("tui construction: {error}"))?;
5520 let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5521 let input = TerminalInput::mock(rx);
5522 let (host, _log) = FakeHost::new();
5523 let options = InteractiveRuntimeOptions {
5524 hardware_cursor: enabled,
5525 size: (80, 24),
5526 ..InteractiveRuntimeOptions::default()
5527 };
5528 let mut runtime = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5529
5530 runtime
5531 .paint_now()
5532 .map_err(|error| format!("initial paint: {error}"))?;
5533
5534 let bytes = captured.snapshot();
5535 let expected_position = bytes
5536 .windows(expected.len())
5537 .rposition(|window| window == expected)
5538 .ok_or("missing configured cursor visibility sequence")?;
5539 let unexpected_position = bytes
5540 .windows(unexpected.len())
5541 .rposition(|window| window == unexpected);
5542 if unexpected_position.is_some_and(|position| position > expected_position) {
5543 return Err("configured cursor visibility was not the final decision".to_owned());
5544 }
5545 }
5546 Ok(())
5547 }
5548
5549 #[tokio::test]
5550 async fn bash_stays_interruptible_and_rejects_overlap() -> Result<(), String> {
5551 let (mut rt, log) = try_make_runtime()?;
5552 let _ = rt.dispatch_bash("hang", false).await;
5553 let _ = rt.dispatch_bash("second", false).await;
5554 assert_eq!(
5555 rt.last_error.as_deref(),
5556 Some("a bash command is already running")
5557 );
5558 tokio::time::timeout(Duration::from_secs(1), log.bash_started.notified())
5559 .await
5560 .map_err(|_| "bash operation did not start".to_owned())?;
5561 assert_eq!(rt.view.editor.border, EditorBorder::Bash);
5562
5563 let _ = rt.dispatch_interrupt().await;
5564 assert_eq!(*log.aborts.lock().await, 1);
5565 assert_eq!(
5566 log.bashes.lock().await.as_slice(),
5567 &[("hang".to_owned(), false)]
5568 );
5569 let completion = tokio::time::timeout(
5570 Duration::from_secs(1),
5571 rt.prompt_operations.tasks.join_next(),
5572 )
5573 .await
5574 .map_err(|_| "bash operation did not finish after abort".to_owned())?
5575 .ok_or_else(|| "bash operation task was missing".to_owned())?;
5576 assert!(rt.handle_prompt_completion(completion));
5577 assert_eq!(rt.view.editor.border, EditorBorder::Muted);
5578 Ok(())
5579 }
5580
5581 fn make_runtime() -> (InteractiveRuntime<SharedWriter, FakeHost>, Arc<ActionLog>) {
5582 match try_make_runtime() {
5583 Ok(runtime) => runtime,
5584 Err(error) => std::panic::resume_unwind(Box::new(error)),
5585 }
5586 }
5587
5588 #[tokio::test]
5589 async fn dispatch_submit_calls_prompt_on_host() {
5590 let (mut rt, log) = make_runtime();
5591 let _ = rt
5592 .dispatch_action(ViewAction::Submit {
5593 text: "hello".to_owned(),
5594 })
5595 .await;
5596 let prompts = log.prompts.lock().await.clone();
5597 assert_eq!(prompts, vec!["hello".to_owned()]);
5598 }
5599
5600 #[tokio::test]
5601 async fn startup_options_dispatch_prompts_images_and_migrations_in_order() -> Result<(), String>
5602 {
5603 let (host, log) = FakeHost::new();
5604 let image = ImageContent::new("AA==", "image/png");
5605 let options = InteractiveRuntimeOptions {
5606 initial_message: Some("first".to_owned()),
5607 initial_images: vec![image.clone()],
5608 remaining_messages: vec!["second".to_owned(), "third".to_owned()],
5609 migrations: MigrationResult {
5610 migrated_auth_providers: vec!["openrouter".to_owned()],
5611 deprecation_warnings: vec!["legacy extension directory".to_owned()],
5612 },
5613 ..InteractiveRuntimeOptions::default()
5614 };
5615 let writer = SharedWriter::new();
5616 let tui = Tui::new(
5617 writer,
5618 Size::new(80, 24),
5619 Position::ORIGIN,
5620 8,
5621 TerminalCapabilities::default(),
5622 )
5623 .map_err(|error| format!("tui construction failed: {error}"))?;
5624 let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
5625 let input = TerminalInput::mock(rx);
5626 let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
5627
5628 assert!(rt.initialize_run().await);
5629 while let Some(completion) = rt.prompt_operations.tasks.join_next().await {
5630 rt.handle_prompt_completion(completion);
5631 if rt.prompt_operations.tasks.is_empty() {
5632 rt.enqueue_next_startup_prompt().await;
5633 }
5634 }
5635
5636 assert_eq!(
5637 log.prompts.lock().await.as_slice(),
5638 ["first", "second", "third"]
5639 );
5640 assert_eq!(
5641 log.prompt_images.lock().await.as_slice(),
5642 [vec![image], Vec::new(), Vec::new()]
5643 );
5644 assert!(
5645 rt.view
5646 .diagnostics
5647 .entries
5648 .iter()
5649 .any(|entry| { entry.message == "Migrated credentials to auth.json: openrouter" })
5650 );
5651 assert!(
5652 rt.view
5653 .diagnostics
5654 .entries
5655 .iter()
5656 .any(|entry| { entry.message == "legacy extension directory" })
5657 );
5658 Ok(())
5659 }
5660
5661 #[tokio::test]
5662 async fn dispatch_quit_exits_without_prompting() {
5663 let (mut rt, log) = make_runtime();
5664 let outcome = rt
5665 .dispatch_action(ViewAction::Submit {
5666 text: "/quit".to_owned(),
5667 })
5668 .await;
5669 assert_eq!(outcome, ActionOutcome::Exit);
5670 assert!(log.prompts.lock().await.is_empty());
5671 }
5672
5673 #[tokio::test]
5674 async fn dispatch_interrupt_calls_abort() {
5675 let (mut rt, log) = make_runtime();
5676 let _ = rt.dispatch_action(ViewAction::Interrupt).await;
5677 assert_eq!(*log.aborts.lock().await, 1);
5678 }
5679
5680 #[tokio::test]
5681 async fn dispatch_compact_passes_through() {
5682 let (mut rt, log) = make_runtime();
5683 let _ = rt
5684 .dispatch_action(ViewAction::Compact {
5685 instructions: Some("focus on tools".to_owned()),
5686 })
5687 .await;
5688 assert_eq!(
5689 *log.compacts.lock().await,
5690 vec![Some("focus on tools".to_owned())]
5691 );
5692 }
5693
5694 #[tokio::test]
5695 async fn dispatch_bash_routes_to_execute_bash() {
5696 let (mut rt, log) = make_runtime();
5697 let _ = rt
5698 .dispatch_action(ViewAction::SubmitBash {
5699 command: "ls".to_owned(),
5700 exclude_from_context: true,
5701 })
5702 .await;
5703 let bashes = log.bashes.lock().await.clone();
5704 assert_eq!(bashes, vec![("ls".to_owned(), true)]);
5705 }
5706
5707 #[tokio::test]
5708 async fn dispatch_slash_command_with_args() {
5709 let (mut rt, log) = make_runtime();
5710 let _ = rt
5711 .dispatch_action(ViewAction::SlashCommand {
5712 name: "name".to_owned(),
5713 args: "my session".to_owned(),
5714 })
5715 .await;
5716 let prompts = log.prompts.lock().await.clone();
5717 assert_eq!(prompts, vec!["/name my session".to_owned()]);
5718 }
5719
5720 #[tokio::test]
5721 async fn dispatch_bang_prefix_routes_to_bash_not_prompt() {
5722 let (mut rt, log) = make_runtime();
5723 let _ = rt
5724 .dispatch_action(ViewAction::Submit {
5725 text: "!ls -la".to_owned(),
5726 })
5727 .await;
5728 let bashes = log.bashes.lock().await.clone();
5729 assert_eq!(bashes, vec![("ls -la".to_owned(), false)]);
5730 let prompts = log.prompts.lock().await.clone();
5731 assert!(prompts.is_empty());
5732 }
5733
5734 #[tokio::test]
5735 async fn dispatch_double_bang_routes_to_excluded_bash() {
5736 let (mut rt, log) = make_runtime();
5737 let _ = rt
5738 .dispatch_action(ViewAction::Submit {
5739 text: "!!rm -rf /tmp/x".to_owned(),
5740 })
5741 .await;
5742 let bashes = log.bashes.lock().await.clone();
5743 assert_eq!(bashes, vec![("rm -rf /tmp/x".to_owned(), true)]);
5744 }
5745
5746 #[tokio::test]
5747 async fn dispatch_clear_editor_empties_view() {
5748 let (mut rt, _log) = make_runtime();
5749 rt.view.editor.text = "draft".to_owned();
5750 rt.editor.set_text("draft");
5751 let _ = rt.dispatch_action(ViewAction::ClearEditor).await;
5752 assert!(rt.view.editor.text.is_empty());
5753 assert!(rt.editor.get_text().is_empty());
5754 }
5755
5756 #[tokio::test]
5757 async fn dispatch_open_overlay_sets_focus_to_overlay() {
5758 let (mut rt, _log) = make_runtime();
5759 let _ = rt
5760 .dispatch_action(ViewAction::ShowOverlay {
5761 kind: OverlayKind::ShortcutHelp,
5762 })
5763 .await;
5764 assert_eq!(rt.view.focus, FocusArea::Overlay);
5765 assert!(rt.view.overlay.is_some());
5766 }
5767
5768 #[tokio::test]
5769 async fn dispatch_dismiss_overlay_restores_editor_focus() {
5770 let (mut rt, _log) = make_runtime();
5771 let _ = rt
5772 .dispatch_action(ViewAction::ShowOverlay {
5773 kind: OverlayKind::Changelog,
5774 })
5775 .await;
5776 assert_eq!(rt.view.focus, FocusArea::Overlay);
5777 let _ = rt.dispatch_action(ViewAction::DismissOverlay).await;
5778 assert_eq!(rt.view.focus, FocusArea::Editor);
5779 assert!(rt.view.overlay.is_none());
5780 }
5781
5782 #[tokio::test]
5783 async fn project_event_agent_start_sets_streaming_status() {
5784 let mut view = ViewState::empty();
5785 project_event(&mut view, &AgentSessionEvent::AgentStart);
5786 assert!(view.streaming);
5787 assert!(view.status.is_some());
5788 }
5789
5790 #[tokio::test]
5791 async fn project_event_agent_end_clears_status() {
5792 let mut view = ViewState::empty();
5793 project_event(&mut view, &AgentSessionEvent::AgentStart);
5794 project_event(
5795 &mut view,
5796 &AgentSessionEvent::AgentEnd {
5797 messages: Vec::new(),
5798 will_retry: false,
5799 },
5800 );
5801 assert!(!view.streaming);
5802 assert!(view.status.is_none());
5803 }
5804
5805 #[test]
5806 fn project_snapshot_projects_summarizing_and_pending_queues() {
5807 let mut view = ViewState::empty();
5808 let snapshot = SessionSnapshot {
5809 activity: SessionActivity::Summarizing,
5810 steering: vec!["steer".to_owned()],
5811 follow_up: vec!["later".to_owned()],
5812 follow_up_mode: super::state::QueueMode::All,
5813 ..SessionSnapshot::default()
5814 };
5815 project_snapshot(&mut view, &snapshot, None);
5816 assert_eq!(
5817 view.status.as_ref().map(|status| status.kind),
5818 Some(StatusKind::BranchSummary)
5819 );
5820 assert_eq!(view.pending.steering[0].text, "steer");
5821 assert_eq!(view.pending.follow_up[0].text, "later");
5822 assert_eq!(view.pending.follow_up_mode, super::state::QueueMode::All);
5823 }
5824
5825 #[test]
5826 fn project_footer_sets_stats_billing_and_border_from_one_snapshot() {
5827 let mut view = ViewState::empty();
5828 project_footer(
5829 &mut view,
5830 &SessionFooterSnapshot {
5831 total_input: 10,
5832 total_output: 20,
5833 total_cache_read: 30,
5834 total_cache_write: 40,
5835 total_cost: 1.25,
5836 context_window: 200,
5837 context_percent: Some(50.0),
5838 provider: Some("provider".to_owned()),
5839 provider_count: 2,
5840 thinking_level: pi_ai::ModelThinkingLevel::High,
5841 subscription: true,
5842 auto_compact: false,
5843 ..SessionFooterSnapshot::default()
5844 },
5845 );
5846 assert_eq!(view.footer.total_input, 10);
5847 assert_eq!(view.footer.total_output, 20);
5848 assert_eq!(view.footer.total_cache_read, 30);
5849 assert_eq!(view.footer.total_cache_write, 40);
5850 assert!((view.footer.total_cost - 1.25).abs() <= f64::EPSILON);
5851 assert_eq!(view.footer.context_percent, Some(50.0));
5852 assert_eq!(view.footer.provider.as_deref(), Some("provider"));
5853 assert_eq!(view.footer.provider_count, 2);
5854 assert_eq!(view.footer.flags.billing, BillingMode::Subscription);
5855 assert!(!view.footer.flags.auto_compact);
5856 assert_eq!(
5857 view.editor.border,
5858 EditorBorder::Thinking(pi_ai::ModelThinkingLevel::High)
5859 );
5860 }
5861
5862 #[tokio::test]
5863 async fn project_event_queue_update_syncs_pending_lists() {
5864 let mut view = ViewState::empty();
5865 project_event(
5866 &mut view,
5867 &AgentSessionEvent::QueueUpdate {
5868 steering: vec!["s1".to_owned()],
5869 follow_up: vec!["f1".to_owned(), "f2".to_owned()],
5870 },
5871 );
5872 assert_eq!(view.pending.steering.len(), 1);
5873 assert_eq!(view.pending.follow_up.len(), 2);
5874 }
5875
5876 #[tokio::test]
5877 async fn project_event_compaction_start_sets_status() -> Result<(), String> {
5878 let mut view = ViewState::empty();
5879 project_event(
5880 &mut view,
5881 &AgentSessionEvent::CompactionStart {
5882 reason: crate::core::agent_session::events::CompactionReason::Manual,
5883 },
5884 );
5885 let status = view.status.as_ref().ok_or("compaction status not set")?;
5886 assert_eq!(status.kind, StatusKind::Compaction);
5887 Ok(())
5888 }
5889
5890 #[tokio::test]
5891 async fn project_event_auto_retry_start_sets_retry_status() -> Result<(), String> {
5892 let mut view = ViewState::empty();
5893 project_event(
5894 &mut view,
5895 &AgentSessionEvent::AutoRetryStart {
5896 attempt: 2,
5897 max_attempts: 5,
5898 delay_ms: 2000,
5899 error_message: "x".to_owned(),
5900 },
5901 );
5902 let status = view.status.as_ref().ok_or("retry status not set")?;
5903 assert_eq!(status.kind, StatusKind::Retry);
5904 Ok(())
5905 }
5906
5907 #[tokio::test]
5908 async fn project_snapshot_streaming_state_projects_to_view() -> Result<(), String> {
5909 let mut view = ViewState::empty();
5910 let snap = SessionSnapshot {
5911 admission_active: true,
5912 activity: SessionActivity::Streaming,
5913 ..SessionSnapshot::default()
5914 };
5915 project_snapshot(&mut view, &snap, None);
5916 assert!(view.streaming);
5917 let status = view.status.as_ref().ok_or("working status not set")?;
5918 assert_eq!(status.kind, StatusKind::Working);
5919 Ok(())
5920 }
5921
5922 #[tokio::test]
5923 async fn pre_stream_admission_projects_busy_and_routes_submit_as_steer() -> Result<(), String> {
5924 let (mut rt, log) = try_make_runtime()?;
5925 let admitted = SessionSnapshot {
5926 admission_active: true,
5927 activity: SessionActivity::Idle,
5928 ..SessionSnapshot::default()
5929 };
5930 *rt.session
5931 .snapshot
5932 .lock()
5933 .unwrap_or_else(std::sync::PoisonError::into_inner) = admitted.clone();
5934 project_snapshot(&mut rt.view, &admitted, None);
5935
5936 assert!(
5937 rt.view.streaming,
5938 "admitted pre-stream session projected idle"
5939 );
5940 let status = rt
5941 .view
5942 .status
5943 .as_ref()
5944 .ok_or("admitted pre-stream session has no working status")?;
5945 assert_eq!(status.kind, StatusKind::Working);
5946
5947 let _ = rt
5948 .dispatch_action(ViewAction::Submit {
5949 text: "second".to_owned(),
5950 })
5951 .await;
5952 assert_eq!(
5953 *log.prompt_behaviors.lock().await,
5954 vec![Some(StreamingBehavior::Steer)]
5955 );
5956 rt.quiesce_prompt_operations().await;
5957 Ok(())
5958 }
5959
5960 #[tokio::test]
5967 async fn agent_session_host_admission_projects_busy_and_routes_steer() -> Result<(), String> {
5968 let AdmissionFixture {
5969 runtime: mut rt,
5970 session_runtime,
5971 hold,
5972 started,
5973 } = make_admission_fixture()?;
5974
5975 let _ = rt
5978 .dispatch_action(ViewAction::Submit {
5979 text: "first".to_owned(),
5980 })
5981 .await;
5982 tokio::time::timeout(Duration::from_secs(2), async {
5983 while !started.load(Ordering::SeqCst) {
5984 tokio::task::yield_now().await;
5985 }
5986 })
5987 .await
5988 .map_err(|_| "provider did not start after first submit".to_owned())?;
5989
5990 let snap = rt.session.snapshot();
5991 assert!(
5992 snap.admission_active,
5993 "AgentSessionHost must report admission from the live session flag"
5994 );
5995 assert_eq!(snap.activity, SessionActivity::Streaming);
5996
5997 let mut view = ViewState::empty();
5998 project_snapshot(&mut view, &snap, None);
5999 assert!(view.streaming, "admitted host snapshot must project busy");
6000 let status = view
6001 .status
6002 .as_ref()
6003 .ok_or("admitted host snapshot missing working status")?;
6004 assert_eq!(status.kind, StatusKind::Working);
6005
6006 project_snapshot(&mut rt.view, &snap, None);
6008 let _ = rt
6009 .dispatch_action(ViewAction::Submit {
6010 text: "second".to_owned(),
6011 })
6012 .await;
6013 let (steering, follow_up) = session_runtime.session().pending_messages();
6014 assert!(
6015 steering.iter().any(|text| text == "second"),
6016 "expected second submit steered into pending queue, got steering={steering:?} follow_up={follow_up:?}"
6017 );
6018
6019 hold.store(false, Ordering::SeqCst);
6023 session_runtime.session().agent().abort();
6024 rt.prompt_operations.tasks.abort_all();
6025 while rt.prompt_operations.tasks.join_next().await.is_some() {}
6026 Ok(())
6027 }
6028
6029 #[tokio::test]
6030 async fn step_ui_ctrl_l_opens_model_selector() -> Result<(), String> {
6031 let (mut rt, _log) = try_make_runtime()?;
6032 rt.step_ui(key(KeyCode::Char('l'), KeyModifiers::CONTROL))
6033 .await
6034 .map_err(|error| format!("model selector step failed: {error}"))?;
6035 assert_eq!(rt.view.focus, FocusArea::Selector);
6036 Ok(())
6037 }
6038
6039 #[tokio::test]
6040 async fn step_ui_ctrl_z_requests_suspend() -> Result<(), String> {
6041 let (mut rt, _log) = try_make_runtime()?;
6042 rt.step_ui(key(KeyCode::Char('z'), KeyModifiers::CONTROL))
6043 .await
6044 .map_err(|error| format!("suspend step failed: {error}"))?;
6045 assert!(rt.exited);
6046 assert_eq!(rt.exit_kind, InteractiveExit::Suspend);
6047 Ok(())
6048 }
6049
6050 #[tokio::test]
6051 async fn step_ui_resize_updates_tui_size_cache() -> Result<(), String> {
6052 let (mut rt, _log) = try_make_runtime()?;
6053 rt.step_ui(UiEvent::Resize {
6054 width: 100,
6055 height: 40,
6056 })
6057 .await
6058 .map_err(|error| format!("resize step failed: {error}"))?;
6059 assert_eq!(rt.tui.size(), Size::new(100, 40));
6060 assert_eq!(rt.view.width, 100);
6061 assert_eq!(rt.view.height, 40);
6062 Ok(())
6063 }
6064
6065 #[tokio::test]
6066 async fn step_ui_paste_inserts_into_editor() -> Result<(), String> {
6067 let (mut rt, _log) = try_make_runtime()?;
6068 rt.step_ui(UiEvent::Paste("hello paste".to_owned()))
6069 .await
6070 .map_err(|error| format!("paste step failed: {error}"))?;
6071 assert_eq!(rt.editor.get_text(), "hello paste");
6072 assert_eq!(rt.view.editor.text, "hello paste");
6073 Ok(())
6074 }
6075
6076 #[tokio::test]
6077 async fn step_session_event_agent_start_marks_streaming() -> Result<(), String> {
6078 let (mut rt, _log) = try_make_runtime()?;
6079 rt.step_session_event(AgentSessionEvent::AgentStart)
6080 .await
6081 .map_err(|error| format!("session event step failed: {error}"))?;
6082 assert!(rt.view.streaming);
6083 assert!(rt.view.status.is_some());
6084 Ok(())
6085 }
6086
6087 #[tokio::test]
6088 async fn flush_coalescer_clears_deadline_and_paints() -> Result<(), String> {
6089 let (mut rt, _log) = try_make_runtime()?;
6090 rt.arm_coalescer();
6091 assert!(rt.coalesce_deadline.is_some());
6092 rt.flush_coalescer()
6093 .map_err(|error| format!("coalescer flush failed: {error}"))?;
6094 assert!(rt.coalesce_deadline.is_none());
6095 Ok(())
6096 }
6097
6098 #[tokio::test]
6099 async fn prompt_stream_paints_an_intermediate_chunk_before_done() -> Result<(), String> {
6100 let writer = SharedWriter::new();
6101 let captured = writer.clone();
6102 let tui = Tui::new(
6103 writer,
6104 Size::new(80, 24),
6105 Position::ORIGIN,
6106 8,
6107 TerminalCapabilities::default(),
6108 )
6109 .map_err(|error| format!("tui construction failed: {error}"))?;
6110 let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6111 let input = TerminalInput::mock(input_rx);
6112 let (host, _log) = FakeHost::new();
6113 host.set_stream_chunks(16);
6114 let options = InteractiveRuntimeOptions {
6115 size: (80, 24),
6116 ..InteractiveRuntimeOptions::default()
6117 };
6118 let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
6119
6120 let _ = rt
6121 .dispatch_action(ViewAction::Submit {
6122 text: "stream".to_owned(),
6123 })
6124 .await;
6125 let shutdown_flag = Arc::clone(&rt.shutdown_flag);
6126 let shutdown = Arc::clone(&rt.shutdown);
6127 tokio::spawn(async move {
6128 tokio::time::sleep(Duration::from_millis(75)).await;
6129 shutdown_flag.store(true, std::sync::atomic::Ordering::SeqCst);
6130 shutdown.notify_one();
6131 });
6132
6133 let exit = tokio::time::timeout(Duration::from_millis(500), rt.run())
6134 .await
6135 .map_err(|_| "runtime blocked on prompt".to_owned())?
6136 .map_err(|error| format!("runtime failed: {error}"))?;
6137 assert_eq!(exit, InteractiveExit::Clean);
6138
6139 let output = String::from_utf8_lossy(&captured.snapshot()).into_owned();
6140 let intermediate = output
6141 .find("stream-chunk-")
6142 .ok_or("no intermediate streaming frame")?;
6143 let done = output.rfind("Done").ok_or("no final Done frame")?;
6144 assert!(
6145 intermediate < done,
6146 "intermediate frame must be written before Done"
6147 );
6148 Ok(())
6149 }
6150
6151 #[tokio::test]
6152 async fn rapid_second_submit_reenters_prompt_with_streaming_behavior() -> Result<(), String> {
6153 let writer = SharedWriter::new();
6154 let tui = Tui::new(
6155 writer,
6156 Size::new(80, 24),
6157 Position::ORIGIN,
6158 8,
6159 TerminalCapabilities::default(),
6160 )
6161 .map_err(|error| format!("tui construction failed: {error}"))?;
6162 let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6163 let input = TerminalInput::mock(input_rx);
6164 let (host, log) = FakeHost::new();
6165 host.set_stream_chunks(16);
6166 let mut rt = InteractiveRuntime::new(
6167 tui,
6168 input,
6169 Arc::new(host),
6170 &InteractiveRuntimeOptions::default(),
6171 );
6172
6173 let _ = rt
6174 .dispatch_action(ViewAction::Submit {
6175 text: "first".to_owned(),
6176 })
6177 .await;
6178 let _ = rt
6179 .dispatch_action(ViewAction::Submit {
6180 text: "second".to_owned(),
6181 })
6182 .await;
6183
6184 assert_eq!(
6185 *log.prompt_behaviors.lock().await,
6186 vec![None, Some(StreamingBehavior::Steer)]
6187 );
6188 rt.quiesce_prompt_operations().await;
6189 Ok(())
6190 }
6191
6192 #[tokio::test]
6193 async fn session_replacement_aborts_and_drains_prompt_operations() -> Result<(), String> {
6194 let writer = SharedWriter::new();
6195 let tui = Tui::new(
6196 writer,
6197 Size::new(80, 24),
6198 Position::ORIGIN,
6199 8,
6200 TerminalCapabilities::default(),
6201 )
6202 .map_err(|error| format!("tui construction failed: {error}"))?;
6203 let (_input_tx, input_rx) = mpsc::unbounded_channel::<UiEvent>();
6204 let input = TerminalInput::mock(input_rx);
6205 let (host, log) = FakeHost::new();
6206 host.set_stream_chunks(8);
6207 let mut rt = InteractiveRuntime::new(
6208 tui,
6209 input,
6210 Arc::new(host),
6211 &InteractiveRuntimeOptions::default(),
6212 );
6213 let _ = rt
6214 .dispatch_action(ViewAction::Submit {
6215 text: "old session".to_owned(),
6216 })
6217 .await;
6218
6219 let _ = rt.dispatch_action(ViewAction::NewSession).await;
6220
6221 assert_eq!(*log.aborts.lock().await, 1);
6222 assert_eq!(*log.new_sessions.lock().await, 1);
6223 assert!(rt.prompt_operations.tasks.is_empty());
6224 assert!(rt.prompt_operations.aborts.is_empty());
6225 Ok(())
6226 }
6227
6228 #[tokio::test]
6229 async fn viewport_bottom_row_tracks_terminal_resize() -> Result<(), String> {
6230 let (mut rt, _log) = try_make_runtime()?;
6231 assert_eq!(rt.viewport_bottom_row(), 23);
6232
6233 rt.step_ui(UiEvent::Resize {
6234 width: 100,
6235 height: 41,
6236 })
6237 .await
6238 .map_err(|error| format!("resize step failed: {error}"))?;
6239
6240 assert_eq!(rt.viewport_bottom_row(), 40);
6241 Ok(())
6242 }
6243 #[test]
6244 fn editor_only_repaint_reuses_long_transcript_chat_components() -> io::Result<()> {
6245 let (mut rt, _log) = make_runtime();
6246 rt.view.messages = (0..1_000)
6247 .map(|index| {
6248 MessageView::User(crate::modes::interactive::messages::UserMessageView {
6249 text: format!("message {index} with **markdown**"),
6250 })
6251 })
6252 .collect();
6253 rt.chat_prefix_cache = None;
6254 rt.chat_prefix_len = usize::MAX;
6255 rt.chat_tail_cache = None;
6256 rt.chat_dirty = true;
6257 rt.paint_frame()?;
6258
6259 let prefix_before = rt
6260 .chat_prefix_cache
6261 .as_deref()
6262 .map(|component| std::ptr::from_ref(component).cast::<()>())
6263 .ok_or_else(|| io::Error::other("missing prefix cache"))?;
6264 let tail_before = rt
6265 .chat_tail_cache
6266 .as_deref()
6267 .map(|component| std::ptr::from_ref(component).cast::<()>())
6268 .ok_or_else(|| io::Error::other("missing tail cache"))?;
6269
6270 rt.editor.set_text("editor-only change");
6271 rt.view.editor.text = "editor-only change".to_owned();
6272 rt.paint_frame()?;
6273
6274 assert_eq!(rt.chat_prefix_len, 999);
6275 assert_eq!(
6276 rt.chat_prefix_cache
6277 .as_deref()
6278 .map(|component| std::ptr::from_ref(component).cast::<()>()),
6279 Some(prefix_before)
6280 );
6281 assert_eq!(
6282 rt.chat_tail_cache
6283 .as_deref()
6284 .map(|component| std::ptr::from_ref(component).cast::<()>()),
6285 Some(tail_before)
6286 );
6287 Ok(())
6288 }
6289
6290 #[test]
6291 fn installed_product_panic_hook_emits_complete_restore_sequence() -> io::Result<()> {
6292 const CHILD_ENV: &str = "PI_TEST_PRODUCT_PANIC_HOOK_PATH";
6293 if let Some(path) = std::env::var_os(CHILD_ENV) {
6294 let writer = std::fs::OpenOptions::new()
6295 .create(true)
6296 .append(true)
6297 .open(path)?;
6298 let emergency = Arc::new(std::sync::atomic::AtomicBool::new(false));
6299 let _restore = install_product_panic_emergency_hook(emergency, writer);
6300 #[allow(clippy::panic)]
6304 {
6305 panic!("intentional product panic-hook fixture");
6306 }
6307 }
6308
6309 let directory = tempfile::tempdir()?;
6310 let capture = directory.path().join("panic-restore.bin");
6311 let output = std::process::Command::new(std::env::current_exe()?)
6312 .args([
6313 "--exact",
6314 "modes::interactive::runtime::tests::installed_product_panic_hook_emits_complete_restore_sequence",
6315 "--nocapture",
6316 ])
6317 .env(CHILD_ENV, &capture)
6318 .output()?;
6319 assert!(
6320 !output.status.success(),
6321 "panic fixture unexpectedly succeeded"
6322 );
6323 assert_eq!(
6324 std::fs::read(capture)?,
6325 b"\x1b[?2026l\x1b[<u\x1b[?2004l\x1b[?1004l\x1b[?2031l\x1b[?25h\x1b[0m"
6326 );
6327 Ok(())
6328 }
6329
6330 #[tokio::test]
6331 async fn shared_writer_aggregates_writes_from_two_handles() -> Result<(), String> {
6332 let writer = SharedWriter::new();
6333 let mut a = writer.clone();
6334 let mut b = writer.clone();
6335 a.write_all(b"hello")
6336 .map_err(|error| format!("first write failed: {error}"))?;
6337 b.write_all(b" world")
6338 .map_err(|error| format!("second write failed: {error}"))?;
6339 assert_eq!(writer.snapshot(), b"hello world");
6340 Ok(())
6341 }
6342
6343 #[tokio::test]
6344 async fn open_overlay_then_dismiss_restores_focus_and_clears_state() {
6345 let (mut rt, _log) = make_runtime();
6346 rt.input_state
6347 .set_last_sigint_for_test(Some(std::time::Instant::now()));
6348 let _ = rt
6349 .dispatch_action(ViewAction::ShowOverlay {
6350 kind: OverlayKind::Login,
6351 })
6352 .await;
6353 let _ = rt.dispatch_action(ViewAction::DismissOverlay).await;
6354 assert!(rt.input_state.last_sigint().is_none());
6355 assert!(rt.input_state.last_escape().is_none());
6356 assert_eq!(rt.view.focus, FocusArea::Editor);
6357 }
6358
6359 #[tokio::test]
6360 async fn select_confirmed_session_invokes_switch_session() {
6361 let (mut rt, log) = make_runtime();
6362 let _ = rt
6363 .dispatch_action(ViewAction::SelectConfirmed {
6364 selector: SelectorKind::Session,
6365 value: "/tmp/sess.json".to_owned(),
6366 })
6367 .await;
6368 let switches = log.switches.lock().await.clone();
6369 assert_eq!(switches, vec!["/tmp/sess.json".to_owned()]);
6370 assert_eq!(rt.view.focus, FocusArea::Editor);
6371 }
6372
6373 #[tokio::test]
6374 async fn select_cancelled_restores_editor_focus() {
6375 let (mut rt, _log) = make_runtime();
6376 rt.view.focus = FocusArea::Selector;
6377 let _ = rt.dispatch_action(ViewAction::SelectCancelled).await;
6378 assert_eq!(rt.view.focus, FocusArea::Editor);
6379 }
6380
6381 #[tokio::test]
6382 async fn draw_timeout_constant_matches_master_plan() {
6383 assert_eq!(DRAW_TIMEOUT, Duration::from_secs(5));
6384 }
6385
6386 #[tokio::test]
6387 async fn coalesce_window_constant_matches_master_plan() {
6388 assert_eq!(BACKGROUND_COALESCE_WINDOW, Duration::from_millis(16));
6389 }
6390
6391 #[tokio::test]
6392 async fn enqueue_settle_runs_on_next_loop_turn() -> Result<(), String> {
6393 let (mut rt, _log) = try_make_runtime()?;
6394 rt.enqueue_settle(vec![settled_lines(vec![Line::raw("settled")])]);
6395 assert!(rt.pending_settle.is_some());
6396 if let Some(blocks) = rt.pending_settle.take() {
6398 rt.commit_settle(blocks)
6399 .map_err(|error| format!("settle commit failed: {error}"))?;
6400 }
6401 assert!(rt.pending_settle.is_none());
6402 Ok(())
6403 }
6404
6405 #[tokio::test]
6406 async fn request_shutdown_exits_main_loop_cleanly() -> Result<(), String> {
6407 let (mut rt, _log) = try_make_runtime()?;
6408 rt.request_shutdown();
6409 let exit = tokio::time::timeout(Duration::from_millis(500), rt.run())
6410 .await
6411 .map_err(|_| "runtime did not return after shutdown".to_owned())?
6412 .map_err(|error| format!("runtime shutdown failed: {error}"))?;
6413 assert_eq!(exit, InteractiveExit::Clean);
6414 Ok(())
6415 }
6416
6417 #[tokio::test]
6418 async fn plain_enter_submits_via_on_submit_channel() -> Result<(), String> {
6419 let (mut rt, log) = try_make_runtime()?;
6420 rt.submit_tx
6421 .send("hello enter".to_owned())
6422 .map_err(|error| format!("submit channel closed: {error}"))?;
6423 rt.step_ui(key(KeyCode::F(24), KeyModifiers::NONE))
6424 .await
6425 .map_err(|error| format!("submit step failed: {error}"))?;
6426 let prompts = log.prompts.lock().await.clone();
6427 assert_eq!(prompts, vec!["hello enter".to_owned()]);
6428 Ok(())
6429 }
6430
6431 #[tokio::test]
6432 async fn rebind_session_channels_reloads_snapshot() {
6433 let (mut rt, _log) = make_runtime();
6434 rt.view.streaming = true;
6435 rt.rebind_session_channels().await;
6436 assert!(!rt.view.streaming);
6437 }
6438
6439 #[tokio::test]
6440 async fn open_model_selector_installs_component_and_focus() {
6441 let (mut rt, _log) = make_runtime();
6442 let outcome = rt.dispatch_action(ViewAction::OpenModelSelector).await;
6443 assert_eq!(outcome, ActionOutcome::Repaint);
6444 assert_eq!(rt.view.focus, FocusArea::Selector);
6445 assert!(rt.active_selector.is_some());
6446 assert_eq!(rt.active_selector_kind, Some(SelectorKind::Model));
6447 }
6448
6449 #[tokio::test]
6450 async fn selector_confirm_channel_routes_to_switch_session() -> Result<(), String> {
6451 let (mut rt, log) = try_make_runtime()?;
6452 let _ = rt.dispatch_action(ViewAction::OpenSessionPicker).await;
6453 rt.select_tx
6454 .send((SelectorKind::Session, "/tmp/from-select.jsonl".to_owned()))
6455 .map_err(|error| format!("selector channel closed: {error}"))?;
6456 rt.step_ui(key(KeyCode::F(24), KeyModifiers::NONE))
6457 .await
6458 .map_err(|error| format!("selector step failed: {error}"))?;
6459 let switches = log.switches.lock().await.clone();
6460 assert_eq!(switches, vec!["/tmp/from-select.jsonl".to_owned()]);
6461 assert_eq!(rt.view.focus, FocusArea::Editor);
6462 assert!(rt.active_selector.is_none());
6463 Ok(())
6464 }
6465
6466 #[tokio::test]
6467 async fn streaming_submit_uses_prompt_with_steer_behavior() -> Result<(), String> {
6468 let writer = SharedWriter::new();
6469 let caps = TerminalCapabilities::default();
6470 let tui = Tui::new(writer, Size::new(80, 24), Position::ORIGIN, 8, caps)
6471 .map_err(|error| format!("tui construction failed: {error}"))?;
6472 let (_tx, rx) = mpsc::unbounded_channel::<UiEvent>();
6473 let input = TerminalInput::mock(rx);
6474 let (host, log) = FakeHost::new();
6475 *host
6476 .snapshot
6477 .lock()
6478 .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6479 activity: SessionActivity::Streaming,
6480 admission_active: true,
6481 ..SessionSnapshot::default()
6482 };
6483 let options = InteractiveRuntimeOptions {
6484 size: (80, 24),
6485 ..InteractiveRuntimeOptions::default()
6486 };
6487 let mut rt = InteractiveRuntime::new(tui, input, Arc::new(host), &options);
6488 let _ = rt
6489 .dispatch_action(ViewAction::Submit {
6490 text: "steer me".to_owned(),
6491 })
6492 .await;
6493 let prompts = log.prompts.lock().await.clone();
6494 assert_eq!(prompts, vec!["steer me".to_owned()]);
6495 assert!(log.steers.lock().await.is_empty());
6496 Ok(())
6497 }
6498
6499 #[tokio::test]
6500 async fn suspend_action_sets_suspend_exit_not_clean_shutdown() {
6501 let (mut rt, _log) = make_runtime();
6502 let outcome = rt.dispatch_action(ViewAction::Suspend).await;
6503 assert_eq!(outcome, ActionOutcome::Suspend);
6504 assert!(!rt.shutdown_flag.load(std::sync::atomic::Ordering::SeqCst));
6505 }
6506
6507 #[tokio::test]
6508 async fn external_editor_action_requests_outer_terminal_handoff() {
6509 let (mut rt, _log) = make_runtime();
6510 let outcome = rt.dispatch_action(ViewAction::ExternalEditor).await;
6511 assert_eq!(outcome, ActionOutcome::ExternalEditor);
6512 }
6513
6514 #[tokio::test]
6515 async fn display_toggles_update_existing_assistant_and_tool_messages() {
6516 let (mut rt, _log) = make_runtime();
6517 rt.view
6518 .messages
6519 .push(MessageView::Assistant(AssistantMessageView {
6520 message: AssistantMessage::new(
6521 "test-api",
6522 "test-provider",
6523 "test-model",
6524 pi_agent::now_millis(),
6525 ),
6526 hide_thinking: false,
6527 hidden_thinking_label: "Thinking hidden".to_owned(),
6528 streaming: false,
6529 }));
6530 project_event(
6531 &mut rt.view,
6532 &AgentSessionEvent::ToolExecutionStart {
6533 tool_call_id: "tool-1".to_owned(),
6534 tool_name: "read".to_owned(),
6535 args: serde_json::Map::new(),
6536 },
6537 );
6538
6539 assert_eq!(
6540 rt.dispatch_action(ViewAction::ToggleThinking).await,
6541 ActionOutcome::Repaint
6542 );
6543 assert_eq!(
6544 rt.dispatch_action(ViewAction::ToggleToolExpand).await,
6545 ActionOutcome::Repaint
6546 );
6547 assert!(
6548 rt.view.messages.iter().any(
6549 |message| matches!(message, MessageView::Assistant(view) if view.hide_thinking)
6550 )
6551 );
6552 assert!(
6553 rt.view
6554 .messages
6555 .iter()
6556 .any(|message| matches!(message, MessageView::Tool(view) if view.state.expanded))
6557 );
6558 }
6559
6560 #[test]
6561 fn effective_extension_shortcuts_reject_invalid_reserved_and_use_last_registration() {
6562 use pi_ext::adapters::ShortcutRegistration;
6563
6564 let shortcuts = build_effective_extension_shortcuts(&[
6565 ShortcutRegistration {
6566 key: "ctrl+not-a-key".to_owned(),
6567 description: Some("invalid".to_owned()),
6568 extension_path: Some("invalid.ts".to_owned()),
6569 },
6570 ShortcutRegistration {
6571 key: "ctrl+c".to_owned(),
6572 description: Some("reserved".to_owned()),
6573 extension_path: Some("reserved.ts".to_owned()),
6574 },
6575 ShortcutRegistration {
6576 key: "alt+ctrl+y".to_owned(),
6577 description: Some("first".to_owned()),
6578 extension_path: Some("first.ts".to_owned()),
6579 },
6580 ShortcutRegistration {
6581 key: "CTRL+ALT+Y".to_owned(),
6582 description: Some("last".to_owned()),
6583 extension_path: Some("last.ts".to_owned()),
6584 },
6585 ]);
6586
6587 assert_eq!(shortcuts.len(), 1);
6588 assert_eq!(shortcuts[0].key, "ctrl+alt+y");
6589 assert_eq!(shortcuts[0].dispatch_key, "CTRL+ALT+Y");
6590 assert_eq!(shortcuts[0].description.as_deref(), Some("last"));
6591 assert_eq!(shortcuts[0].source.as_deref(), Some("last.ts"));
6592 let non_reserved = KeyEvent::new(
6593 KeyCode::Char('y'),
6594 KeyModifiers::CONTROL | KeyModifiers::ALT,
6595 );
6596 assert!(key_matches_parsed(&non_reserved, &shortcuts[0].parsed));
6597 let hints = shortcut_hints(&shortcuts);
6598 assert_eq!(hints[0].action, "last");
6599 }
6600
6601 #[tokio::test]
6602 async fn reserved_extension_conflict_falls_through_to_native_binding() -> Result<(), String> {
6603 let (mut rt, _log) = try_make_runtime()?;
6604 rt.editor.set_text("draft");
6605 rt.view.editor.text = "draft".to_owned();
6606 rt.effective_extension_shortcuts =
6607 build_effective_extension_shortcuts(&[pi_ext::adapters::ShortcutRegistration {
6608 key: "ctrl+c".to_owned(),
6609 description: Some("must not run".to_owned()),
6610 extension_path: Some("extension.ts".to_owned()),
6611 }]);
6612 assert!(rt.effective_extension_shortcuts.is_empty());
6613
6614 rt.step_ui(key(KeyCode::Char('c'), KeyModifiers::CONTROL))
6615 .await
6616 .map_err(|error| format!("native fallthrough failed: {error}"))?;
6617 assert!(rt.editor.get_text().is_empty());
6618 Ok(())
6619 }
6620
6621 #[test]
6622 fn focused_slot_projection_retains_generation_and_typed_key_payload() {
6623 let (mut rt, _log) = make_runtime();
6624 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6625 key: "editor.status".to_owned(),
6626 generation: 7,
6627 placement: SlotPlacement::AboveEditor,
6628 height: 1,
6629 runs: vec![vec![pi_ext::protocol::StyledRun {
6630 text: "focused".to_owned(),
6631 style: pi_ext::protocol::Style::default(),
6632 }]],
6633 focusable: true,
6634 cursor: None,
6635 overlay_options: None,
6636 });
6637 rt.project_extension_slot(slot);
6638 assert_eq!(rt.focused_extension_slot.as_deref(), Some("editor.status"));
6639 assert_eq!(rt.view.focus, FocusArea::Widget);
6640 assert_eq!(
6641 rt.extension_slots
6642 .get("editor.status")
6643 .map(|slot| slot.generation),
6644 Some(7)
6645 );
6646
6647 let event = UiEvent::Key(KeyEvent::new_with_kind(
6648 KeyCode::Enter,
6649 KeyModifiers::ALT,
6650 crossterm::event::KeyEventKind::Repeat,
6651 ));
6652 assert_eq!(
6653 ui_event_wire(&event),
6654 UiEventWire::Key {
6655 code: "enter".to_owned(),
6656 modifiers: KeyModifiersWire {
6657 alt: Some(true),
6658 ..KeyModifiersWire::default()
6659 },
6660 kind: KeyEventKindWire::Repeat,
6661 }
6662 );
6663 assert_eq!(
6664 encode_terminal_input(&event).as_deref(),
6665 Some("\u{1b}[13;3:2u")
6666 );
6667 }
6668
6669 #[tokio::test]
6670 async fn focused_extension_preserves_global_escape_and_interrupt_bindings() -> Result<(), String>
6671 {
6672 let (mut rt, _log) = try_make_runtime()?;
6673 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6674 key: "editor.focused".to_owned(),
6675 generation: 1,
6676 placement: SlotPlacement::AboveEditor,
6677 height: 1,
6678 runs: vec![vec![pi_ext::protocol::StyledRun {
6679 text: "focused".to_owned(),
6680 style: pi_ext::protocol::Style::default(),
6681 }]],
6682 focusable: true,
6683 cursor: None,
6684 overlay_options: None,
6685 });
6686 rt.project_extension_slot(slot);
6687 rt.editor.set_text("draft");
6688 rt.view.editor.text = "draft".to_owned();
6689 let escape = key(KeyCode::Esc, KeyModifiers::NONE);
6690 let interrupt = key(KeyCode::Char('c'), KeyModifiers::CONTROL);
6691
6692 assert_eq!(rt.view.focus, FocusArea::Widget);
6693 assert!(is_global_app_binding(&escape));
6694 assert!(is_global_app_binding(&interrupt));
6695 assert!(!rt.route_extension_input(&escape));
6696 assert!(!rt.route_extension_input(&interrupt));
6697
6698 rt.handle_ui_event(interrupt)
6699 .await
6700 .map_err(|error| format!("global interrupt routing failed: {error}"))?;
6701 assert!(rt.editor.get_text().is_empty());
6702 Ok(())
6703 }
6704
6705 #[tokio::test]
6706 async fn escape_releases_focused_extension_widget_before_later_typing() -> Result<(), String> {
6707 let (mut rt, log) = try_make_runtime()?;
6708 *rt.session
6710 .snapshot
6711 .lock()
6712 .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6713 admission_active: true,
6714 activity: SessionActivity::Streaming,
6715 ..SessionSnapshot::default()
6716 };
6717 rt.view.streaming = true;
6718 rt.view.status = Some(SessionStatus {
6719 kind: StatusKind::Working,
6720 frame: 0,
6721 message: "Working…".to_owned(),
6722 });
6723 rt.editor.set_text("draft");
6724 rt.view.editor.text = "draft".to_owned();
6725
6726 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6727 key: "editor.focused".to_owned(),
6728 generation: 1,
6729 placement: SlotPlacement::AboveEditor,
6730 height: 1,
6731 runs: vec![vec![pi_ext::protocol::StyledRun {
6732 text: "focused".to_owned(),
6733 style: pi_ext::protocol::Style::default(),
6734 }]],
6735 focusable: true,
6736 cursor: None,
6737 overlay_options: None,
6738 });
6739 rt.project_extension_slot(slot);
6740
6741 rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6742 .await
6743 .map_err(|error| format!("widget escape routing failed: {error}"))?;
6744 assert!(rt.focused_extension_slot.is_none());
6745 assert_eq!(rt.view.focus, FocusArea::Editor);
6746 assert!(rt.view.widgets_above.iter().all(|widget| !widget.focused));
6747 assert_eq!(*log.aborts.lock().await, 0);
6749 assert_eq!(rt.editor.get_text(), "draft");
6750 assert_eq!(rt.view.editor.text, "draft");
6751 assert!(rt.view.streaming);
6752
6753 rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6755 .await
6756 .map_err(|error| format!("second escape interrupt failed: {error}"))?;
6757 assert_eq!(*log.aborts.lock().await, 1);
6758 assert_eq!(rt.editor.get_text(), "draft");
6759
6760 rt.handle_ui_event(key(KeyCode::Char('x'), KeyModifiers::NONE))
6761 .await
6762 .map_err(|error| format!("editor typing after widget escape failed: {error}"))?;
6763 assert_eq!(rt.editor.get_text(), "draftx");
6764 Ok(())
6765 }
6766
6767 #[tokio::test]
6768 async fn escape_dismisses_focused_extension_overlay_and_restores_editor() -> Result<(), String>
6769 {
6770 let (mut rt, log) = try_make_runtime()?;
6771 *rt.session
6772 .snapshot
6773 .lock()
6774 .unwrap_or_else(std::sync::PoisonError::into_inner) = SessionSnapshot {
6775 admission_active: true,
6776 activity: SessionActivity::Streaming,
6777 ..SessionSnapshot::default()
6778 };
6779 rt.view.streaming = true;
6780 rt.view.status = Some(SessionStatus {
6781 kind: StatusKind::Working,
6782 frame: 0,
6783 message: "Working…".to_owned(),
6784 });
6785 rt.editor.set_text("keep-me");
6786 rt.view.editor.text = "keep-me".to_owned();
6787
6788 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6789 key: "overlay.focused".to_owned(),
6790 generation: 1,
6791 placement: SlotPlacement::Overlay,
6792 height: 1,
6793 runs: vec![vec![pi_ext::protocol::StyledRun {
6794 text: "focused overlay".to_owned(),
6795 style: pi_ext::protocol::Style::default(),
6796 }]],
6797 focusable: true,
6798 cursor: None,
6799 overlay_options: Some(pi_ext::protocol::OverlaySpec::default()),
6800 });
6801 rt.project_extension_slot(slot);
6802 assert_eq!(rt.view.focus, FocusArea::Overlay);
6803
6804 rt.handle_ui_event(key(KeyCode::Esc, KeyModifiers::NONE))
6805 .await
6806 .map_err(|error| format!("overlay escape routing failed: {error}"))?;
6807 assert!(rt.focused_extension_slot.is_none());
6808 assert!(rt.view.extension_overlay_slot.is_none());
6809 assert_eq!(rt.view.focus, FocusArea::Editor);
6810 assert_eq!(*log.aborts.lock().await, 0);
6811 assert_eq!(rt.editor.get_text(), "keep-me");
6812 assert!(rt.view.streaming);
6813
6814 rt.handle_ui_event(key(KeyCode::Char('x'), KeyModifiers::NONE))
6815 .await
6816 .map_err(|error| format!("editor typing after overlay escape failed: {error}"))?;
6817 assert_eq!(rt.editor.get_text(), "keep-mex");
6818 Ok(())
6819 }
6820
6821 #[test]
6822 fn non_capturing_overlay_preserves_editor_focus_and_structured_metadata() -> Result<(), String>
6823 {
6824 let (mut rt, _log) = make_runtime();
6825 let link = pi_ext::protocol::Hyperlink {
6826 id: Some("docs".to_owned()),
6827 uri: "https://example.com/docs".to_owned(),
6828 };
6829 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6830 key: "overlay.help".to_owned(),
6831 generation: 3,
6832 placement: SlotPlacement::Overlay,
6833 height: 1,
6834 runs: vec![vec![pi_ext::protocol::StyledRun {
6835 text: "help".to_owned(),
6836 style: pi_ext::protocol::Style {
6837 bold: Some(true),
6838 link: Some(link.clone()),
6839 ..pi_ext::protocol::Style::default()
6840 },
6841 }]],
6842 focusable: true,
6843 cursor: None,
6844 overlay_options: Some(pi_ext::protocol::OverlaySpec {
6845 non_capturing: true,
6846 ..pi_ext::protocol::OverlaySpec::default()
6847 }),
6848 });
6849
6850 rt.project_extension_slot(slot);
6851 assert_eq!(rt.view.focus, FocusArea::Editor);
6852 assert!(rt.focused_extension_slot.is_none());
6853 let projected = rt
6854 .view
6855 .extension_overlay_slot
6856 .as_ref()
6857 .ok_or_else(|| "structured overlay was not projected".to_owned())?;
6858 assert_eq!(projected.lines[0][0].style.link.as_ref(), Some(&link));
6859 Ok(())
6860 }
6861
6862 #[tokio::test]
6863 async fn extension_input_dialog_temporarily_owns_then_restores_editor() {
6864 let (mut rt, _log) = make_runtime();
6865 rt.editor.set_text("draft prompt");
6866 rt.view.editor.text = "draft prompt".to_owned();
6867 rt.view.editor.placeholder = "Type a message…".to_owned();
6868 rt.begin_extension_dialog(HostUiRequest::Input {
6869 id: 17,
6870 request: pi_ext::protocol::InputRequest {
6871 title: "Extension input".to_owned(),
6872 placeholder: Some("value".to_owned()),
6873 options_meta: pi_ext::protocol::DialogOptions::default(),
6874 },
6875 })
6876 .await;
6877 assert_eq!(rt.editor.get_text(), "");
6878 assert_eq!(rt.view.editor.placeholder, "value");
6879
6880 let outcome = rt.submit_text("answer".to_owned(), false).await;
6881 assert_eq!(outcome, ActionOutcome::Repaint);
6882 assert!(rt.pending_extension_dialog.is_none());
6883 assert_eq!(rt.editor.get_text(), "draft prompt");
6884 assert_eq!(rt.view.editor.placeholder, "Type a message…");
6885 }
6886
6887 #[tokio::test]
6888 async fn reload_cancels_pending_extension_dialog_and_restores_editor() {
6889 let (mut rt, _log) = make_runtime();
6890 rt.editor.set_text("draft prompt");
6891 rt.view.editor.text = "draft prompt".to_owned();
6892 rt.view.editor.placeholder = "Type a message…".to_owned();
6893 rt.begin_extension_dialog(HostUiRequest::Input {
6894 id: 18,
6895 request: pi_ext::protocol::InputRequest {
6896 title: "Extension input".to_owned(),
6897 placeholder: None,
6898 options_meta: pi_ext::protocol::DialogOptions::default(),
6899 },
6900 })
6901 .await;
6902
6903 let outcome = rt.dispatch_action(ViewAction::Reload).await;
6904 assert_eq!(outcome, ActionOutcome::Repaint);
6905 assert!(rt.pending_extension_dialog.is_none());
6906 assert_eq!(rt.editor.get_text(), "draft prompt");
6907 assert_eq!(rt.view.editor.placeholder, "Type a message…");
6908 }
6909
6910 #[tokio::test]
6911 async fn extension_confirmation_renders_title_and_message() {
6912 let (mut rt, _log) = make_runtime();
6913 rt.begin_extension_dialog(HostUiRequest::Confirm {
6914 id: 19,
6915 request: pi_ext::protocol::ConfirmRequest {
6916 title: "Verification confirm prompt".to_owned(),
6917 message: "Choose Yes".to_owned(),
6918 options_meta: pi_ext::protocol::DialogOptions::default(),
6919 },
6920 })
6921 .await;
6922 let editor = std::mem::replace(&mut rt.editor, Editor::with_defaults());
6923 let selector = rt.active_selector.take();
6924 let mut root = rt.build_root(editor, selector);
6925 let area = Rect::new(0, 0, 80, 24);
6926 let mut buffer = Buffer::empty(area);
6927
6928 root.render(area, &mut buffer);
6929
6930 let visible = buffer
6931 .content()
6932 .iter()
6933 .map(ratatui::buffer::Cell::symbol)
6934 .collect::<String>();
6935 assert!(visible.contains("Verification confirm prompt"));
6936 assert!(visible.contains("Choose Yes"));
6937 }
6938
6939 #[test]
6940 fn extension_slot_update_and_dispose_projects_live_widgets() {
6941 let (mut rt, _log) = make_runtime();
6942 let slot = pi_ext::sanitize::sanitize_slot(&pi_ext::protocol::UiSlot {
6943 key: "status".to_owned(),
6944 generation: 1,
6945 placement: SlotPlacement::AboveEditor,
6946 height: 1,
6947 runs: vec![vec![pi_ext::protocol::StyledRun {
6948 text: "extension ready".to_owned(),
6949 style: pi_ext::protocol::Style::default(),
6950 }]],
6951 focusable: false,
6952 cursor: None,
6953 overlay_options: None,
6954 });
6955 rt.project_extension_slot(slot);
6956 assert_eq!(rt.view.widgets_above.len(), 1);
6957 assert_eq!(
6958 rt.view.widgets_above[0].slot.lines[0][0].text,
6959 "extension ready"
6960 );
6961
6962 rt.dispose_extension_slot("status");
6963 assert!(rt.view.widgets_above.is_empty());
6964 }
6965
6966 #[test]
6967 fn terminal_input_codec_covers_extension_rewrite_keyspace() -> Result<(), String> {
6968 let events = [
6969 UiEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
6970 UiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
6971 UiEvent::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
6972 UiEvent::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)),
6973 UiEvent::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
6974 UiEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
6975 ];
6976 for event in events {
6977 let encoded = encode_terminal_input(&event)
6978 .ok_or_else(|| format!("unsupported event: {event:?}"))?;
6979 assert_eq!(decode_terminal_input(encoded), event);
6980 }
6981 Ok(())
6982 }
6983
6984 #[tokio::test]
6985 async fn copy_last_assistant_produces_feedback() {
6986 let (mut rt, log) = make_runtime();
6987 *log.last_text.lock().await = Some("assistant says hi".to_owned());
6988 let _ = rt.dispatch_action(ViewAction::CopyLastAssistant).await;
6989 let had_status =
6990 rt.view.status.as_ref().is_some_and(|s| {
6991 s.message.contains("Copied") || s.message.contains("No assistant")
6992 });
6993 let had_error = rt
6994 .last_error
6995 .as_ref()
6996 .is_some_and(|e| e.contains("clipboard") || e.contains("Failed"));
6997 assert!(had_status || had_error);
6998 }
6999
7000 #[test]
7001 fn stored_runtime_error_is_painted_and_replaced() -> Result<(), String> {
7002 let (mut rt, _log) = try_make_runtime()?;
7003
7004 rt.record_err(Err("clipboard failed".to_owned()));
7005 assert!(rt.coalesce_deadline.is_some());
7006 rt.paint_frame()
7007 .map_err(|error| format!("paint failed: {error}"))?;
7008
7009 let runtime_errors = rt
7010 .view
7011 .diagnostics
7012 .entries
7013 .iter()
7014 .filter(|entry| entry.source == "runtime")
7015 .collect::<Vec<_>>();
7016 assert_eq!(runtime_errors.len(), 1);
7017 assert_eq!(runtime_errors[0].severity, DiagnosticSeverity::Error);
7018 assert_eq!(runtime_errors[0].message, "clipboard failed");
7019
7020 rt.record_error("extension failed".to_owned());
7021 rt.paint_frame()
7022 .map_err(|error| format!("second paint failed: {error}"))?;
7023 let runtime_errors = rt
7024 .view
7025 .diagnostics
7026 .entries
7027 .iter()
7028 .filter(|entry| entry.source == "runtime")
7029 .collect::<Vec<_>>();
7030 assert_eq!(runtime_errors.len(), 1);
7031 assert_eq!(runtime_errors[0].message, "extension failed");
7032 Ok(())
7033 }
7034
7035 #[tokio::test]
7036 async fn project_event_message_start_user_appears_in_chat() {
7037 let mut view = ViewState::empty();
7038 let user = pi_agent::user_text("hi from user", std::iter::empty());
7039 project_event(
7040 &mut view,
7041 &AgentSessionEvent::MessageStart { message: user },
7042 );
7043 assert!(
7044 view.messages
7045 .iter()
7046 .any(|m| matches!(m, MessageView::User(_)))
7047 );
7048 }
7049
7050 #[tokio::test]
7051 async fn project_event_tool_start_appears_in_chat() {
7052 let mut view = ViewState::empty();
7053 project_event(
7054 &mut view,
7055 &AgentSessionEvent::ToolExecutionStart {
7056 tool_call_id: "t1".to_owned(),
7057 tool_name: "read".to_owned(),
7058 args: serde_json::Map::from_iter([(
7059 "path".to_owned(),
7060 serde_json::Value::String("a.rs".to_owned()),
7061 )]),
7062 },
7063 );
7064 assert!(
7065 view.messages
7066 .iter()
7067 .any(|m| matches!(m, MessageView::Tool(_)))
7068 );
7069 }
7070
7071 #[test]
7072 fn root_render_clips_overflow_and_keeps_editor_visible() {
7073 let mut view = ViewState::empty();
7074 for index in 0..30 {
7075 let message = pi_agent::user_text(
7076 format!("message {index}: {}", "overflow ".repeat(20)),
7077 std::iter::empty(),
7078 );
7079 project_event(&mut view, &AgentSessionEvent::MessageStart { message });
7080 }
7081 let mut editor = Editor::with_defaults();
7082 editor.set_text("EDITOR_VISIBLE");
7083 let mut root = InteractiveRoot::build(&view, editor, None);
7084 let area = Rect::new(0, 0, 80, 24);
7085 let mut buffer = Buffer::empty(area);
7086
7087 root.render(area, &mut buffer);
7088
7089 let visible = buffer
7090 .content()
7091 .iter()
7092 .map(ratatui::buffer::Cell::symbol)
7093 .collect::<String>();
7094 assert!(visible.contains("EDITOR_VISIBLE"));
7095 }
7096
7097 #[test]
7098 fn live_root_render_applies_extension_overlay_layout() {
7099 let mut root = InteractiveRoot {
7100 pre_editor: Vec::new(),
7101 editor: Editor::with_defaults(),
7102 post_editor: Vec::new(),
7103 overlay: Some(Box::new(pi_tui::components::Text::with_padding(
7104 "OVERLAY".to_owned(),
7105 0,
7106 0,
7107 ))),
7108 overlay_spec: Some(pi_tui::layout::OverlaySpec {
7109 width: Some(pi_tui::layout::SizeValue::cells(12)),
7110 row: Some(pi_tui::layout::SizeValue::cells(5)),
7111 col: Some(pi_tui::layout::SizeValue::cells(9)),
7112 ..pi_tui::layout::OverlaySpec::default()
7113 }),
7114 selector: None,
7115 dialog_title: None,
7116 focus: FocusArea::Editor,
7117 };
7118 let area = Rect::new(0, 0, 40, 12);
7119 let mut buffer = Buffer::empty(area);
7120
7121 root.render(area, &mut buffer);
7122
7123 assert_eq!(
7124 buffer.cell((9, 5)).map(ratatui::buffer::Cell::symbol),
7125 Some("O")
7126 );
7127 let overlay_row = (9..21)
7128 .filter_map(|column| buffer.cell((column, 5)))
7129 .map(ratatui::buffer::Cell::symbol)
7130 .collect::<String>();
7131 assert!(overlay_row.contains("OVERLAY"));
7132 let origin_row = (0..9)
7133 .filter_map(|column| buffer.cell((column, 0)))
7134 .map(ratatui::buffer::Cell::symbol)
7135 .collect::<String>();
7136 assert!(!origin_row.contains("OVERLAY"));
7137 }
7138
7139 #[tokio::test]
7140 async fn dispatch_slash_compact_without_instructions_calls_compact() {
7141 let (mut rt, log) = make_runtime();
7142 let outcome = rt
7143 .dispatch_action(ViewAction::SlashCommand {
7144 name: "compact".to_owned(),
7145 args: String::new(),
7146 })
7147 .await;
7148 assert_eq!(outcome, ActionOutcome::None);
7149 assert_eq!(*log.compacts.lock().await, vec![None]);
7150 assert!(log.prompts.lock().await.is_empty());
7151 }
7152
7153 #[tokio::test]
7154 async fn dispatch_typed_compact_trims_custom_instructions() {
7155 let (mut rt, log) = make_runtime();
7156 let outcome = rt
7157 .dispatch_action(ViewAction::Submit {
7158 text: " /compact focus on tools ".to_owned(),
7159 })
7160 .await;
7161 assert_eq!(outcome, ActionOutcome::None);
7162 assert_eq!(
7163 *log.compacts.lock().await,
7164 vec![Some("focus on tools".to_owned())]
7165 );
7166 assert!(log.prompts.lock().await.is_empty());
7167 }
7168
7169 #[tokio::test]
7170 async fn dispatch_typed_fork_opens_user_message_selector() {
7171 let (mut rt, log) = make_runtime();
7172 let outcome = rt
7173 .dispatch_action(ViewAction::Submit {
7174 text: "/fork".to_owned(),
7175 })
7176 .await;
7177 assert_eq!(outcome, ActionOutcome::Repaint);
7178 assert_eq!(rt.view.focus, FocusArea::Selector);
7179 assert!(rt.active_selector.is_some());
7180 assert_eq!(rt.active_selector_kind, Some(SelectorKind::Fork));
7181 assert!(log.prompts.lock().await.is_empty());
7182 }
7183
7184 #[tokio::test]
7185 async fn dispatch_typed_resume_opens_session_selector() {
7186 let (mut rt, log) = make_runtime();
7187 let outcome = rt
7188 .dispatch_action(ViewAction::Submit {
7189 text: "/resume".to_owned(),
7190 })
7191 .await;
7192 assert_eq!(outcome, ActionOutcome::Repaint);
7193 assert_eq!(rt.view.focus, FocusArea::Selector);
7194 assert!(rt.active_selector.is_some());
7195 assert_eq!(rt.active_selector_kind, Some(SelectorKind::Session));
7196 assert!(log.prompts.lock().await.is_empty());
7197 }
7198
7199 #[tokio::test]
7200 async fn dispatch_typed_reload_awaits_host_and_repaints() {
7201 let (mut rt, log) = make_runtime();
7202 let outcome = rt
7203 .dispatch_action(ViewAction::Submit {
7204 text: "/reload".to_owned(),
7205 })
7206 .await;
7207 assert_eq!(outcome, ActionOutcome::Repaint);
7208 assert_eq!(*log.reloads.lock().await, 1);
7209 assert!(log.prompts.lock().await.is_empty());
7210 }
7211
7212 #[tokio::test]
7213 async fn dispatch_unknown_slash_command_routes_through_prompt() {
7214 let (mut rt, log) = make_runtime();
7215 let outcome = rt
7216 .dispatch_action(ViewAction::SlashCommand {
7217 name: "foo".to_owned(),
7218 args: "custom args".to_owned(),
7219 })
7220 .await;
7221 assert_eq!(outcome, ActionOutcome::None);
7222 assert_eq!(
7223 *log.prompts.lock().await,
7224 vec!["/foo custom args".to_owned()]
7225 );
7226 }
7227}