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