Skip to main content

pi/interactive/
state.rs

1use std::collections::VecDeque;
2use std::path::{Path, PathBuf};
3
4use bubbles::list::{DefaultDelegate, Item as ListItem, List};
5
6use crate::agent::QueueMode;
7use crate::autocomplete::{
8    AutocompleteCatalog, AutocompleteItem, AutocompleteProvider, AutocompleteResponse,
9};
10use crate::extensions::ExtensionUiRequest;
11use crate::model::{ContentBlock, Message as ModelMessage};
12use crate::models::OAuthConfig;
13use crate::session::SiblingBranch;
14use crate::session_index::{SessionIndex, SessionMeta};
15use crate::session_picker::delete_session_file;
16use crate::theme::Theme;
17use serde_json::Value;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub(super) enum PendingLoginKind {
21    OAuth,
22    ApiKey,
23    /// Device flow (RFC 8628) — user completes browser authorization and Pi polls for token.
24    DeviceFlow,
25}
26
27#[derive(Debug, Clone)]
28pub(super) struct PendingOAuth {
29    pub(super) provider: String,
30    pub(super) kind: PendingLoginKind,
31    pub(super) verifier: String,
32    /// OAuth config for extension-registered providers (None for built-in like anthropic).
33    pub(super) oauth_config: Option<OAuthConfig>,
34    /// Device code for RFC 8628 device flow providers.
35    pub(super) device_code: Option<String>,
36    /// The redirect URI used in the authorization request (needed for token exchange per RFC 6749 §4.1.3).
37    pub(super) redirect_uri: Option<String>,
38}
39
40/// Tool output line count above which blocks auto-collapse.
41pub(super) const TOOL_AUTO_COLLAPSE_THRESHOLD: usize = 20;
42/// Number of preview lines to show when a tool block is collapsed.
43pub(super) const TOOL_COLLAPSE_PREVIEW_LINES: usize = 5;
44
45/// A message in the conversation history.
46#[derive(Debug, Clone)]
47pub struct ConversationMessage {
48    pub role: MessageRole,
49    pub content: String,
50    pub thinking: Option<String>,
51    /// Per-message collapse state for tool outputs.
52    pub collapsed: bool,
53}
54
55impl ConversationMessage {
56    /// Create a non-tool message (never collapsed).
57    pub(super) const fn new(role: MessageRole, content: String, thinking: Option<String>) -> Self {
58        Self {
59            role,
60            content,
61            thinking,
62            collapsed: false,
63        }
64    }
65
66    /// Create a tool output message with auto-collapse for large outputs.
67    pub(super) fn tool(content: String) -> Self {
68        let line_count = memchr::memchr_iter(b'\n', content.as_bytes()).count() + 1;
69        Self {
70            role: MessageRole::Tool,
71            content,
72            thinking: None,
73            collapsed: line_count > TOOL_AUTO_COLLAPSE_THRESHOLD,
74        }
75    }
76}
77
78/// Role of a message.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MessageRole {
81    User,
82    Assistant,
83    Tool,
84    System,
85}
86
87/// State of the agent processing.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum AgentState {
90    /// Ready for input.
91    Idle,
92    /// Processing user request.
93    Processing,
94    /// Executing a tool.
95    ToolRunning,
96}
97
98/// Input mode for the TUI.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum InputMode {
101    /// Single-line input mode (default).
102    SingleLine,
103    /// Multi-line input mode (activated with Shift+Enter or \).
104    MultiLine,
105}
106
107#[derive(Debug, Clone)]
108pub enum PendingInput {
109    Text(String),
110    Content(Vec<ContentBlock>),
111    Continue,
112}
113
114/// Autocomplete dropdown state.
115#[derive(Debug)]
116pub(super) struct AutocompleteState {
117    /// The autocomplete provider that generates suggestions.
118    pub(super) provider: AutocompleteProvider,
119    /// Whether the dropdown is currently visible.
120    pub(super) open: bool,
121    /// Current list of suggestions.
122    pub(super) items: Vec<AutocompleteItem>,
123    /// Index of the currently selected item, or `None` when the popup is open
124    /// but the user has not yet navigated with arrow keys / Tab.
125    pub(super) selected: Option<usize>,
126    /// The range of text to replace when accepting a suggestion.
127    pub(super) replace_range: std::ops::Range<usize>,
128    /// Maximum number of items to display in the dropdown.
129    pub(super) max_visible: usize,
130}
131
132impl AutocompleteState {
133    pub(super) const fn new(cwd: PathBuf, catalog: AutocompleteCatalog) -> Self {
134        Self {
135            provider: AutocompleteProvider::new(cwd, catalog),
136            open: false,
137            items: Vec::new(),
138            selected: None,
139            replace_range: 0..0,
140            max_visible: 10,
141        }
142    }
143
144    pub(super) fn close(&mut self) {
145        self.open = false;
146        self.items.clear();
147        self.selected = None;
148        self.replace_range = 0..0;
149    }
150
151    pub(super) fn open_with(&mut self, response: AutocompleteResponse) {
152        if response.items.is_empty() {
153            self.close();
154            return;
155        }
156
157        // Preserve the selected item across periodic refreshes when the edit
158        // target range is unchanged. This keeps arrow-key navigation stable
159        // while typing (e.g. `/model ...`) even if suggestions are recomputed.
160        let previous_selection = if response.replace == self.replace_range {
161            self.selected_item().cloned()
162        } else {
163            None
164        };
165
166        self.open = true;
167        self.items = response.items;
168        self.selected = previous_selection.and_then(|selected| {
169            self.items.iter().position(|candidate| {
170                candidate.kind == selected.kind
171                    && candidate.insert == selected.insert
172                    && candidate.label == selected.label
173            })
174        });
175        self.replace_range = response.replace;
176    }
177
178    pub(super) fn select_next(&mut self) {
179        if !self.items.is_empty() {
180            self.selected = Some(match self.selected {
181                Some(idx) => (idx + 1) % self.items.len(),
182                None => 0,
183            });
184        }
185    }
186
187    pub(super) fn select_prev(&mut self) {
188        if !self.items.is_empty() {
189            self.selected = Some(match self.selected {
190                Some(idx) => idx.checked_sub(1).unwrap_or(self.items.len() - 1),
191                None => self.items.len() - 1,
192            });
193        }
194    }
195
196    pub(super) fn selected_item(&self) -> Option<&AutocompleteItem> {
197        self.selected.and_then(|idx| self.items.get(idx))
198    }
199
200    /// Returns the scroll offset for the dropdown view.
201    pub(super) const fn scroll_offset(&self) -> usize {
202        match self.selected {
203            Some(idx) if idx >= self.max_visible => idx - self.max_visible + 1,
204            _ => 0,
205        }
206    }
207}
208
209/// Session picker overlay state for /resume command.
210#[derive(Debug)]
211pub(super) struct SessionPickerOverlay {
212    /// Full list of available sessions.
213    pub(super) all_sessions: Vec<SessionMeta>,
214    /// List of available sessions.
215    pub(super) sessions: Vec<SessionMeta>,
216    /// Query used for typed filtering.
217    query: String,
218    /// Index of the currently selected session.
219    pub(super) selected: usize,
220    /// Maximum number of sessions to display.
221    pub(super) max_visible: usize,
222    /// Whether we're in delete confirmation mode.
223    pub(super) confirm_delete: bool,
224    /// Status message to render in the picker overlay.
225    pub(super) status_message: Option<String>,
226    /// Base directory for session storage (used for index cleanup).
227    sessions_root: Option<PathBuf>,
228}
229
230impl SessionPickerOverlay {
231    pub(super) fn new(sessions: Vec<SessionMeta>) -> Self {
232        Self {
233            all_sessions: sessions.clone(),
234            sessions,
235            query: String::new(),
236            selected: 0,
237            max_visible: 10,
238            confirm_delete: false,
239            status_message: None,
240            sessions_root: None,
241        }
242    }
243
244    pub(super) fn new_with_root(
245        sessions: Vec<SessionMeta>,
246        sessions_root: Option<PathBuf>,
247    ) -> Self {
248        Self {
249            all_sessions: sessions.clone(),
250            sessions,
251            query: String::new(),
252            selected: 0,
253            max_visible: 10,
254            confirm_delete: false,
255            status_message: None,
256            sessions_root,
257        }
258    }
259
260    pub(super) fn select_next(&mut self) {
261        if !self.sessions.is_empty() {
262            self.selected = (self.selected + 1) % self.sessions.len();
263        }
264    }
265
266    pub(super) fn select_prev(&mut self) {
267        if !self.sessions.is_empty() {
268            self.selected = self
269                .selected
270                .checked_sub(1)
271                .unwrap_or(self.sessions.len() - 1);
272        }
273    }
274
275    pub(super) fn select_page_down(&mut self) {
276        if self.sessions.is_empty() {
277            return;
278        }
279        let step = self.max_visible.saturating_sub(1).max(1);
280        self.selected = (self.selected + step).min(self.sessions.len().saturating_sub(1));
281    }
282
283    pub(super) fn select_page_up(&mut self) {
284        if self.sessions.is_empty() {
285            return;
286        }
287        let step = self.max_visible.saturating_sub(1).max(1);
288        self.selected = self.selected.saturating_sub(step);
289    }
290
291    pub(super) fn selected_session(&self) -> Option<&SessionMeta> {
292        self.sessions.get(self.selected)
293    }
294
295    pub(super) fn query(&self) -> &str {
296        &self.query
297    }
298
299    pub(super) fn has_query(&self) -> bool {
300        !self.query.is_empty()
301    }
302
303    pub(super) fn push_chars<I: IntoIterator<Item = char>>(&mut self, chars: I) {
304        let mut changed = false;
305        for ch in chars {
306            if !ch.is_control() {
307                self.query.push(ch);
308                changed = true;
309            }
310        }
311        if changed {
312            self.rebuild_filtered_sessions();
313        }
314    }
315
316    pub(super) fn pop_char(&mut self) {
317        if self.query.pop().is_some() {
318            self.rebuild_filtered_sessions();
319        }
320    }
321
322    /// Returns the scroll offset for the dropdown view.
323    pub(super) const fn scroll_offset(&self) -> usize {
324        if self.selected < self.max_visible {
325            0
326        } else {
327            self.selected - self.max_visible + 1
328        }
329    }
330
331    /// Remove the selected session from the list and adjust selection.
332    pub(super) fn remove_selected(&mut self) {
333        let Some(selected_session) = self.selected_session().cloned() else {
334            return;
335        };
336        self.all_sessions
337            .retain(|session| session.path != selected_session.path);
338        self.rebuild_filtered_sessions();
339        // Clear confirmation state
340        self.confirm_delete = false;
341    }
342
343    pub(super) fn delete_selected(&mut self) -> crate::error::Result<()> {
344        let Some(session_meta) = self.selected_session().cloned() else {
345            return Ok(());
346        };
347        let path = PathBuf::from(&session_meta.path);
348        delete_session_file(&path)?;
349        if let Some(root) = self.sessions_root.as_ref() {
350            let index = SessionIndex::for_sessions_root(root);
351            let _ = index.delete_session_path(&path);
352        }
353        self.remove_selected();
354        Ok(())
355    }
356
357    fn rebuild_filtered_sessions(&mut self) {
358        let query = self.query.trim().to_ascii_lowercase();
359        if query.is_empty() {
360            self.sessions = self.all_sessions.clone();
361        } else {
362            self.sessions = self
363                .all_sessions
364                .iter()
365                .filter(|session| Self::session_matches_query(session, &query))
366                .cloned()
367                .collect();
368        }
369
370        if self.sessions.is_empty() {
371            self.selected = 0;
372        } else if self.selected >= self.sessions.len() {
373            self.selected = self.sessions.len() - 1;
374        }
375    }
376
377    fn session_matches_query(session: &SessionMeta, query_lower: &str) -> bool {
378        let in_name = session
379            .name
380            .as_deref()
381            .is_some_and(|name| name.to_ascii_lowercase().contains(query_lower));
382        let in_id = session.id.to_ascii_lowercase().contains(query_lower);
383        let in_file_name = Path::new(&session.path)
384            .file_name()
385            .and_then(std::ffi::OsStr::to_str)
386            .is_some_and(|file_name| file_name.to_ascii_lowercase().contains(query_lower));
387        let in_timestamp = session.timestamp.to_ascii_lowercase().contains(query_lower);
388        let in_message_count = session.message_count.to_string().contains(query_lower);
389
390        in_name || in_id || in_file_name || in_timestamp || in_message_count
391    }
392}
393
394/// Settings selector overlay state for /settings command.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub(super) enum SettingsUiEntry {
397    Summary,
398    Theme,
399    SteeringMode,
400    FollowUpMode,
401    DefaultPermissive,
402    QuietStartup,
403    CollapseChangelog,
404    HideThinkingBlock,
405    ShowHardwareCursor,
406    DoubleEscapeAction,
407    EditorPaddingX,
408    AutocompleteMaxVisible,
409}
410
411#[derive(Debug, Clone)]
412pub(super) enum ThemePickerItem {
413    BuiltIn(&'static str),
414    File { path: PathBuf, name: String },
415}
416
417#[derive(Debug)]
418pub(super) struct ThemePickerOverlay {
419    pub(super) items: Vec<ThemePickerItem>,
420    pub(super) selected: usize,
421    pub(super) max_visible: usize,
422}
423
424impl ThemePickerOverlay {
425    pub(super) fn new(cwd: &Path) -> Self {
426        let mut items = Vec::new();
427        items.push(ThemePickerItem::BuiltIn("dark"));
428        items.push(ThemePickerItem::BuiltIn("light"));
429        items.push(ThemePickerItem::BuiltIn("solarized"));
430        items.extend(Theme::discover_themes(cwd).into_iter().map(|path| {
431            let name = Theme::load(&path).map_or_else(
432                |_| {
433                    path.file_stem().map_or_else(
434                        || "unknown".to_string(),
435                        |s| s.to_string_lossy().to_string(),
436                    )
437                },
438                |t| t.name,
439            );
440            ThemePickerItem::File { path, name }
441        }));
442        Self {
443            items,
444            selected: 0,
445            max_visible: 10,
446        }
447    }
448
449    pub(super) fn select_next(&mut self) {
450        if !self.items.is_empty() {
451            self.selected = (self.selected + 1) % self.items.len();
452        }
453    }
454
455    pub(super) fn select_prev(&mut self) {
456        if !self.items.is_empty() {
457            self.selected = self.selected.checked_sub(1).unwrap_or(self.items.len() - 1);
458        }
459    }
460
461    pub(super) fn select_page_down(&mut self) {
462        if self.items.is_empty() {
463            return;
464        }
465        let step = self.max_visible.saturating_sub(1).max(1);
466        self.selected = (self.selected + step).min(self.items.len().saturating_sub(1));
467    }
468
469    pub(super) fn select_page_up(&mut self) {
470        if self.items.is_empty() {
471            return;
472        }
473        let step = self.max_visible.saturating_sub(1).max(1);
474        self.selected = self.selected.saturating_sub(step);
475    }
476
477    pub(super) const fn scroll_offset(&self) -> usize {
478        if self.selected < self.max_visible {
479            0
480        } else {
481            self.selected - self.max_visible + 1
482        }
483    }
484
485    pub(super) fn selected_item(&self) -> Option<&ThemePickerItem> {
486        self.items.get(self.selected)
487    }
488}
489
490#[derive(Debug)]
491pub(super) struct SettingsUiState {
492    pub(super) entries: Vec<SettingsUiEntry>,
493    pub(super) selected: usize,
494    pub(super) max_visible: usize,
495}
496
497impl SettingsUiState {
498    pub(super) fn new() -> Self {
499        Self {
500            entries: vec![
501                SettingsUiEntry::Summary,
502                SettingsUiEntry::Theme,
503                SettingsUiEntry::SteeringMode,
504                SettingsUiEntry::FollowUpMode,
505                SettingsUiEntry::DefaultPermissive,
506                SettingsUiEntry::QuietStartup,
507                SettingsUiEntry::CollapseChangelog,
508                SettingsUiEntry::HideThinkingBlock,
509                SettingsUiEntry::ShowHardwareCursor,
510                SettingsUiEntry::DoubleEscapeAction,
511                SettingsUiEntry::EditorPaddingX,
512                SettingsUiEntry::AutocompleteMaxVisible,
513            ],
514            selected: 0,
515            max_visible: 10,
516        }
517    }
518
519    pub(super) fn select_next(&mut self) {
520        if !self.entries.is_empty() {
521            self.selected = (self.selected + 1) % self.entries.len();
522        }
523    }
524
525    pub(super) fn select_prev(&mut self) {
526        if !self.entries.is_empty() {
527            self.selected = self
528                .selected
529                .checked_sub(1)
530                .unwrap_or(self.entries.len() - 1);
531        }
532    }
533
534    pub(super) fn select_page_down(&mut self) {
535        if self.entries.is_empty() {
536            return;
537        }
538        let step = self.max_visible.saturating_sub(1).max(1);
539        self.selected = (self.selected + step).min(self.entries.len().saturating_sub(1));
540    }
541
542    pub(super) fn select_page_up(&mut self) {
543        if self.entries.is_empty() {
544            return;
545        }
546        let step = self.max_visible.saturating_sub(1).max(1);
547        self.selected = self.selected.saturating_sub(step);
548    }
549
550    pub(super) fn selected_entry(&self) -> Option<SettingsUiEntry> {
551        self.entries.get(self.selected).copied()
552    }
553
554    pub(super) const fn scroll_offset(&self) -> usize {
555        if self.selected < self.max_visible {
556            0
557        } else {
558            self.selected - self.max_visible + 1
559        }
560    }
561}
562
563/// User action choices for a capability prompt.
564#[derive(Debug, Clone, Copy, PartialEq, Eq)]
565pub(super) enum CapabilityAction {
566    AllowOnce,
567    AllowAlways,
568    Deny,
569    DenyAlways,
570}
571
572impl CapabilityAction {
573    pub(super) const ALL: [Self; 4] = [
574        Self::AllowOnce,
575        Self::AllowAlways,
576        Self::Deny,
577        Self::DenyAlways,
578    ];
579
580    pub(super) const fn label(self) -> &'static str {
581        match self {
582            Self::AllowOnce => "Allow Once",
583            Self::AllowAlways => "Allow Always",
584            Self::Deny => "Deny",
585            Self::DenyAlways => "Deny Always",
586        }
587    }
588
589    pub(super) const fn is_allow(self) -> bool {
590        matches!(self, Self::AllowOnce | Self::AllowAlways)
591    }
592
593    pub(super) const fn is_persistent(self) -> bool {
594        matches!(self, Self::AllowAlways | Self::DenyAlways)
595    }
596}
597
598/// Modal overlay for extension capability prompts.
599#[derive(Debug)]
600pub(super) struct CapabilityPromptOverlay {
601    /// The underlying UI request (used to send response).
602    pub(super) request: ExtensionUiRequest,
603    /// Extension that requested the capability.
604    pub(super) extension_id: String,
605    /// Capability being requested (e.g. "exec", "http").
606    pub(super) capability: String,
607    /// Human-readable description of what the capability does.
608    pub(super) description: String,
609    /// Which button is focused.
610    pub(super) focused: usize,
611    /// Auto-deny countdown (remaining seconds).  `None` = no timer.
612    pub(super) auto_deny_secs: Option<u32>,
613}
614
615impl CapabilityPromptOverlay {
616    pub(super) fn from_request(request: ExtensionUiRequest) -> Self {
617        let extension_id = request
618            .payload
619            .get("extension_id")
620            .and_then(Value::as_str)
621            .unwrap_or("<unknown>")
622            .to_string();
623        let capability = request
624            .payload
625            .get("capability")
626            .and_then(Value::as_str)
627            .unwrap_or("unknown")
628            .to_string();
629        let description = request
630            .payload
631            .get("message")
632            .and_then(Value::as_str)
633            .unwrap_or("")
634            .to_string();
635        Self {
636            request,
637            extension_id,
638            capability,
639            description,
640            focused: 0,
641            auto_deny_secs: Some(30),
642        }
643    }
644
645    pub(super) const fn focus_next(&mut self) {
646        self.focused = (self.focused + 1) % CapabilityAction::ALL.len();
647    }
648
649    pub(super) fn focus_prev(&mut self) {
650        self.focused = self
651            .focused
652            .checked_sub(1)
653            .unwrap_or(CapabilityAction::ALL.len() - 1);
654    }
655
656    pub(super) const fn selected_action(&self) -> CapabilityAction {
657        CapabilityAction::ALL[self.focused]
658    }
659
660    /// Returns `true` if this is a capability-specific confirm prompt (not a
661    /// generic extension confirm).
662    pub(super) fn is_capability_prompt(request: &ExtensionUiRequest) -> bool {
663        request.method == "confirm"
664            && request.payload.get("capability").is_some()
665            && request.payload.get("extension_id").is_some()
666    }
667}
668
669/// Runtime state for extension-driven `ui.custom()` overlays.
670#[derive(Debug, Clone, Default)]
671pub(super) struct ExtensionCustomOverlay {
672    /// Extension that owns the active custom overlay.
673    pub(super) extension_id: Option<String>,
674    /// Optional overlay title.
675    pub(super) title: Option<String>,
676    /// Latest rendered frame lines.
677    pub(super) lines: Vec<String>,
678}
679
680/// Branch picker overlay for quick branch switching (Ctrl+B).
681#[derive(Debug)]
682pub(super) struct BranchPickerOverlay {
683    /// Sibling branches at the nearest fork point.
684    pub(super) branches: Vec<SiblingBranch>,
685    /// Which branch is currently selected in the picker.
686    pub(super) selected: usize,
687    /// Maximum visible rows before scrolling.
688    pub(super) max_visible: usize,
689}
690
691impl BranchPickerOverlay {
692    pub(super) fn new(branches: Vec<SiblingBranch>) -> Self {
693        let current_idx = branches.iter().position(|b| b.is_current).unwrap_or(0);
694        Self {
695            branches,
696            selected: current_idx,
697            max_visible: 10,
698        }
699    }
700
701    pub(super) fn select_next(&mut self) {
702        if !self.branches.is_empty() {
703            self.selected = (self.selected + 1) % self.branches.len();
704        }
705    }
706
707    pub(super) fn select_prev(&mut self) {
708        if !self.branches.is_empty() {
709            self.selected = self
710                .selected
711                .checked_sub(1)
712                .unwrap_or(self.branches.len() - 1);
713        }
714    }
715
716    pub(super) fn select_page_down(&mut self) {
717        if self.branches.is_empty() {
718            return;
719        }
720        let step = self.max_visible.saturating_sub(1).max(1);
721        self.selected = (self.selected + step).min(self.branches.len().saturating_sub(1));
722    }
723
724    pub(super) fn select_page_up(&mut self) {
725        if self.branches.is_empty() {
726            return;
727        }
728        let step = self.max_visible.saturating_sub(1).max(1);
729        self.selected = self.selected.saturating_sub(step);
730    }
731
732    pub(super) const fn scroll_offset(&self) -> usize {
733        if self.selected < self.max_visible {
734            0
735        } else {
736            self.selected - self.max_visible + 1
737        }
738    }
739
740    pub(super) fn selected_branch(&self) -> Option<&SiblingBranch> {
741        self.branches.get(self.selected)
742    }
743}
744
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub(super) enum QueuedMessageKind {
747    Steering,
748    FollowUp,
749}
750
751#[derive(Debug)]
752pub(super) struct InteractiveMessageQueue {
753    pub(super) steering: VecDeque<String>,
754    pub(super) follow_up: VecDeque<String>,
755    steering_mode: QueueMode,
756    follow_up_mode: QueueMode,
757}
758
759impl InteractiveMessageQueue {
760    pub(super) const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
761        Self {
762            steering: VecDeque::new(),
763            follow_up: VecDeque::new(),
764            steering_mode,
765            follow_up_mode,
766        }
767    }
768
769    pub(super) const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
770        self.steering_mode = steering_mode;
771        self.follow_up_mode = follow_up_mode;
772    }
773
774    pub(super) fn push_steering(&mut self, text: String) {
775        self.steering.push_back(text);
776    }
777
778    pub(super) fn push_follow_up(&mut self, text: String) {
779        self.follow_up.push_back(text);
780    }
781
782    pub(super) fn pop_steering(&mut self) -> Vec<String> {
783        self.pop_kind(QueuedMessageKind::Steering)
784    }
785
786    pub(super) fn pop_follow_up(&mut self) -> Vec<String> {
787        self.pop_kind(QueuedMessageKind::FollowUp)
788    }
789
790    fn pop_kind(&mut self, kind: QueuedMessageKind) -> Vec<String> {
791        let (queue, mode) = match kind {
792            QueuedMessageKind::Steering => (&mut self.steering, self.steering_mode),
793            QueuedMessageKind::FollowUp => (&mut self.follow_up, self.follow_up_mode),
794        };
795        match mode {
796            QueueMode::All => queue.drain(..).collect(),
797            QueueMode::OneAtATime => queue.pop_front().into_iter().collect(),
798        }
799    }
800
801    pub(super) fn clear_all(&mut self) -> (Vec<String>, Vec<String>) {
802        let steering = self.steering.drain(..).collect();
803        let follow_up = self.follow_up.drain(..).collect();
804        (steering, follow_up)
805    }
806
807    pub(super) fn steering_len(&self) -> usize {
808        self.steering.len()
809    }
810
811    pub(super) fn follow_up_len(&self) -> usize {
812        self.follow_up.len()
813    }
814
815    pub(super) fn steering_front(&self) -> Option<&String> {
816        self.steering.front()
817    }
818
819    pub(super) fn follow_up_front(&self) -> Option<&String> {
820        self.follow_up.front()
821    }
822}
823
824#[derive(Debug)]
825pub(super) struct InjectedMessageQueue {
826    steering: VecDeque<ModelMessage>,
827    follow_up: VecDeque<ModelMessage>,
828    steering_mode: QueueMode,
829    follow_up_mode: QueueMode,
830}
831
832impl InjectedMessageQueue {
833    pub(super) const fn new(steering_mode: QueueMode, follow_up_mode: QueueMode) -> Self {
834        Self {
835            steering: VecDeque::new(),
836            follow_up: VecDeque::new(),
837            steering_mode,
838            follow_up_mode,
839        }
840    }
841
842    pub(super) const fn set_modes(&mut self, steering_mode: QueueMode, follow_up_mode: QueueMode) {
843        self.steering_mode = steering_mode;
844        self.follow_up_mode = follow_up_mode;
845    }
846
847    fn push_kind(&mut self, kind: QueuedMessageKind, message: ModelMessage) {
848        match kind {
849            QueuedMessageKind::Steering => self.steering.push_back(message),
850            QueuedMessageKind::FollowUp => self.follow_up.push_back(message),
851        }
852    }
853
854    pub(super) fn push_steering(&mut self, message: ModelMessage) {
855        self.push_kind(QueuedMessageKind::Steering, message);
856    }
857
858    pub(super) fn push_follow_up(&mut self, message: ModelMessage) {
859        self.push_kind(QueuedMessageKind::FollowUp, message);
860    }
861
862    fn pop_kind(&mut self, kind: QueuedMessageKind) -> Vec<ModelMessage> {
863        let (queue, mode) = match kind {
864            QueuedMessageKind::Steering => (&mut self.steering, self.steering_mode),
865            QueuedMessageKind::FollowUp => (&mut self.follow_up, self.follow_up_mode),
866        };
867        match mode {
868            QueueMode::All => queue.drain(..).collect(),
869            QueueMode::OneAtATime => queue.pop_front().into_iter().collect(),
870        }
871    }
872
873    pub(super) fn pop_steering(&mut self) -> Vec<ModelMessage> {
874        self.pop_kind(QueuedMessageKind::Steering)
875    }
876
877    pub(super) fn pop_follow_up(&mut self) -> Vec<ModelMessage> {
878        self.pop_kind(QueuedMessageKind::FollowUp)
879    }
880}
881
882#[derive(Debug, Clone)]
883pub(super) struct HistoryItem {
884    pub(super) value: String,
885}
886
887impl ListItem for HistoryItem {
888    fn filter_value(&self) -> &str {
889        &self.value
890    }
891}
892
893#[derive(Clone)]
894pub(super) struct HistoryList {
895    // We never render the list UI; we use it as a battle-tested cursor+navigation model.
896    // The final item is always a sentinel representing "empty input".
897    list: List<HistoryItem, DefaultDelegate>,
898}
899
900impl HistoryList {
901    pub(super) fn new() -> Self {
902        let mut list = List::new(
903            vec![HistoryItem {
904                value: String::new(),
905            }],
906            DefaultDelegate::new(),
907            0,
908            0,
909        );
910
911        // Keep behavior minimal/predictable for now; this is used as an index model.
912        list.filtering_enabled = false;
913        list.infinite_scrolling = false;
914
915        // Start at the "empty input" sentinel.
916        list.select(0);
917
918        Self { list }
919    }
920
921    pub(super) fn entries(&self) -> &[HistoryItem] {
922        let items = self.list.items();
923        if items.len() <= 1 {
924            return &[];
925        }
926        &items[..items.len().saturating_sub(1)]
927    }
928
929    pub(super) fn has_entries(&self) -> bool {
930        !self.entries().is_empty()
931    }
932
933    pub(super) fn cursor_is_empty(&self) -> bool {
934        // Sentinel is always the final item.
935        self.list.index() + 1 == self.list.items().len()
936    }
937
938    pub(super) fn reset_cursor(&mut self) {
939        let last = self.list.items().len().saturating_sub(1);
940        self.list.select(last);
941    }
942
943    pub(super) fn push(&mut self, value: String) {
944        let mut items = self.entries().to_vec();
945        items.push(HistoryItem { value });
946        items.push(HistoryItem {
947            value: String::new(),
948        });
949
950        self.list.set_items(items);
951        self.reset_cursor();
952    }
953
954    pub(super) fn cursor_up(&mut self) {
955        self.list.cursor_up();
956    }
957
958    pub(super) fn cursor_down(&mut self) {
959        self.list.cursor_down();
960    }
961
962    pub(super) fn selected_value(&self) -> &str {
963        self.list
964            .selected_item()
965            .map_or("", |item| item.value.as_str())
966    }
967}
968
969/// Progress metrics emitted by long-running tools (e.g. bash).
970#[derive(Debug, Clone)]
971pub(super) struct ToolProgress {
972    pub(super) started_at: std::time::Instant,
973    pub(super) elapsed_ms: u128,
974    pub(super) line_count: usize,
975    pub(super) byte_count: usize,
976    pub(super) timeout_ms: Option<u64>,
977}
978
979impl ToolProgress {
980    pub(super) fn new() -> Self {
981        Self {
982            started_at: std::time::Instant::now(),
983            elapsed_ms: 0,
984            line_count: 0,
985            byte_count: 0,
986            timeout_ms: None,
987        }
988    }
989
990    /// Update from a `details.progress` JSON object emitted by tool callbacks.
991    pub(super) fn update_from_details(&mut self, details: Option<&Value>) {
992        // Always update elapsed from wall clock as fallback.
993        self.elapsed_ms = self.started_at.elapsed().as_millis();
994
995        let Some(details) = details else {
996            return;
997        };
998        if let Some(progress) = details.get("progress") {
999            if let Some(v) = progress.get("elapsedMs").and_then(Value::as_u64) {
1000                self.elapsed_ms = u128::from(v);
1001            }
1002            if let Some(v) = progress.get("lineCount").and_then(Value::as_u64) {
1003                #[allow(clippy::cast_possible_truncation)]
1004                let count = v as usize;
1005                self.line_count = count;
1006            }
1007            if let Some(v) = progress.get("byteCount").and_then(Value::as_u64) {
1008                #[allow(clippy::cast_possible_truncation)]
1009                let count = v as usize;
1010                self.byte_count = count;
1011            }
1012            if let Some(v) = progress.get("timeoutMs").and_then(Value::as_u64) {
1013                self.timeout_ms = Some(v);
1014            }
1015        }
1016    }
1017
1018    /// Format a compact status string like `"Running bash · 3s · 42 lines"`.
1019    pub(super) fn format_display(&self, tool_name: &str) -> String {
1020        let secs = self.elapsed_ms / 1000;
1021        let mut parts = vec![format!("Running {tool_name}"), format!("{secs}s")];
1022        if self.line_count > 0 {
1023            parts.push(format!("{} lines", format_count(self.line_count)));
1024        } else if self.byte_count > 0 {
1025            parts.push(format!("{} bytes", format_count(self.byte_count)));
1026        }
1027        if let Some(timeout_ms) = self.timeout_ms {
1028            let timeout_s = timeout_ms / 1000;
1029            if timeout_s > 0 {
1030                parts.push(format!("timeout {timeout_s}s"));
1031            }
1032        }
1033        parts.join(" \u{2022} ")
1034    }
1035}
1036
1037/// Format a count with K/M suffix for compact display.
1038#[allow(clippy::cast_precision_loss)]
1039pub(super) fn format_count(n: usize) -> String {
1040    if n >= 1_000_000 {
1041        format!("{:.1}M", n as f64 / 1_000_000.0)
1042    } else if n >= 1_000 {
1043        format!("{:.1}K", n as f64 / 1_000.0)
1044    } else {
1045        n.to_string()
1046    }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052
1053    fn model_item(id: &str) -> AutocompleteItem {
1054        AutocompleteItem {
1055            kind: crate::autocomplete::AutocompleteItemKind::Model,
1056            label: id.to_string(),
1057            insert: id.to_string(),
1058            description: None,
1059        }
1060    }
1061
1062    fn response(
1063        replace_range: std::ops::Range<usize>,
1064        items: impl IntoIterator<Item = &'static str>,
1065    ) -> AutocompleteResponse {
1066        AutocompleteResponse {
1067            replace: replace_range,
1068            items: items.into_iter().map(model_item).collect(),
1069        }
1070    }
1071
1072    #[test]
1073    fn autocomplete_refresh_preserves_selected_item_when_replace_range_unchanged() {
1074        let mut state = AutocompleteState::new(PathBuf::from("."), AutocompleteCatalog::default());
1075        state.open_with(response(0..6, ["gpt-4o", "gpt-5.2", "claude-opus-4-5"]));
1076
1077        state.select_next();
1078        state.select_next();
1079        assert_eq!(
1080            state.selected_item().map(|item| item.label.as_str()),
1081            Some("gpt-5.2")
1082        );
1083
1084        // Recompute suggestions (same replace range) in a different order.
1085        state.open_with(response(0..6, ["claude-opus-4-5", "gpt-5.2", "gpt-4o"]));
1086
1087        assert_eq!(
1088            state.selected_item().map(|item| item.label.as_str()),
1089            Some("gpt-5.2")
1090        );
1091    }
1092
1093    #[test]
1094    fn autocomplete_refresh_clears_selection_when_replace_range_changes() {
1095        let mut state = AutocompleteState::new(PathBuf::from("."), AutocompleteCatalog::default());
1096        state.open_with(response(0..6, ["gpt-4o", "gpt-5.2"]));
1097        state.select_next();
1098        assert_eq!(
1099            state.selected_item().map(|item| item.label.as_str()),
1100            Some("gpt-4o")
1101        );
1102
1103        // Cursor/token moved: replace range changed, so selection should reset.
1104        state.open_with(response(2..8, ["gpt-4o", "gpt-5.2"]));
1105        assert!(state.selected_item().is_none());
1106    }
1107
1108    #[test]
1109    fn autocomplete_refresh_clears_selection_when_selected_item_disappears() {
1110        let mut state = AutocompleteState::new(PathBuf::from("."), AutocompleteCatalog::default());
1111        state.open_with(response(0..6, ["gpt-4o", "gpt-5.2"]));
1112        state.select_next();
1113        state.select_next();
1114        assert_eq!(
1115            state.selected_item().map(|item| item.label.as_str()),
1116            Some("gpt-5.2")
1117        );
1118
1119        // Selected suggestion no longer present after refresh.
1120        state.open_with(response(0..6, ["gpt-4o"]));
1121        assert!(state.selected_item().is_none());
1122    }
1123
1124    #[test]
1125    fn settings_ui_includes_default_permissive_toggle() {
1126        let state = SettingsUiState::new();
1127        assert!(state.entries.contains(&SettingsUiEntry::DefaultPermissive));
1128    }
1129}