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 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 pub(super) oauth_config: Option<OAuthConfig>,
34 pub(super) device_code: Option<String>,
36 pub(super) redirect_uri: Option<String>,
38}
39
40pub(super) const TOOL_AUTO_COLLAPSE_THRESHOLD: usize = 20;
42pub(super) const TOOL_COLLAPSE_PREVIEW_LINES: usize = 5;
44
45#[derive(Debug, Clone)]
47pub struct ConversationMessage {
48 pub role: MessageRole,
49 pub content: String,
50 pub thinking: Option<String>,
51 pub collapsed: bool,
53}
54
55impl ConversationMessage {
56 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum MessageRole {
81 User,
82 Assistant,
83 Tool,
84 System,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum AgentState {
90 Idle,
92 Processing,
94 ToolRunning,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum InputMode {
101 SingleLine,
103 MultiLine,
105}
106
107#[derive(Debug, Clone)]
108pub enum PendingInput {
109 Text(String),
110 Content(Vec<ContentBlock>),
111 Continue,
112}
113
114#[derive(Debug)]
116pub(super) struct AutocompleteState {
117 pub(super) provider: AutocompleteProvider,
119 pub(super) open: bool,
121 pub(super) items: Vec<AutocompleteItem>,
123 pub(super) selected: Option<usize>,
126 pub(super) replace_range: std::ops::Range<usize>,
128 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 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 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#[derive(Debug)]
211pub(super) struct SessionPickerOverlay {
212 pub(super) all_sessions: Vec<SessionMeta>,
214 pub(super) sessions: Vec<SessionMeta>,
216 query: String,
218 pub(super) selected: usize,
220 pub(super) max_visible: usize,
222 pub(super) confirm_delete: bool,
224 pub(super) status_message: Option<String>,
226 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 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 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 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#[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#[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#[derive(Debug)]
600pub(super) struct CapabilityPromptOverlay {
601 pub(super) request: ExtensionUiRequest,
603 pub(super) extension_id: String,
605 pub(super) capability: String,
607 pub(super) description: String,
609 pub(super) focused: usize,
611 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 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#[derive(Debug, Clone, Default)]
671pub(super) struct ExtensionCustomOverlay {
672 pub(super) extension_id: Option<String>,
674 pub(super) title: Option<String>,
676 pub(super) lines: Vec<String>,
678}
679
680#[derive(Debug)]
682pub(super) struct BranchPickerOverlay {
683 pub(super) branches: Vec<SiblingBranch>,
685 pub(super) selected: usize,
687 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 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 list.filtering_enabled = false;
913 list.infinite_scrolling = false;
914
915 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 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#[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 pub(super) fn update_from_details(&mut self, details: Option<&Value>) {
992 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 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#[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 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 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 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}