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}
37
38pub(super) const TOOL_AUTO_COLLAPSE_THRESHOLD: usize = 20;
40pub(super) const TOOL_COLLAPSE_PREVIEW_LINES: usize = 5;
42
43#[derive(Debug, Clone)]
45pub struct ConversationMessage {
46 pub role: MessageRole,
47 pub content: String,
48 pub thinking: Option<String>,
49 pub collapsed: bool,
51}
52
53impl ConversationMessage {
54 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum MessageRole {
79 User,
80 Assistant,
81 Tool,
82 System,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum AgentState {
88 Idle,
90 Processing,
92 ToolRunning,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum InputMode {
99 SingleLine,
101 MultiLine,
103}
104
105#[derive(Debug, Clone)]
106pub enum PendingInput {
107 Text(String),
108 Content(Vec<ContentBlock>),
109}
110
111#[derive(Debug)]
113pub(super) struct AutocompleteState {
114 pub(super) provider: AutocompleteProvider,
116 pub(super) open: bool,
118 pub(super) items: Vec<AutocompleteItem>,
120 pub(super) selected: usize,
122 pub(super) replace_range: std::ops::Range<usize>,
124 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 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#[derive(Debug)]
186pub(super) struct SessionPickerOverlay {
187 pub(super) all_sessions: Vec<SessionMeta>,
189 pub(super) sessions: Vec<SessionMeta>,
191 query: String,
193 pub(super) selected: usize,
195 pub(super) max_visible: usize,
197 pub(super) confirm_delete: bool,
199 pub(super) status_message: Option<String>,
201 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 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 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 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#[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#[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#[derive(Debug)]
518pub(super) struct CapabilityPromptOverlay {
519 pub(super) request: ExtensionUiRequest,
521 pub(super) extension_id: String,
523 pub(super) capability: String,
525 pub(super) description: String,
527 pub(super) focused: usize,
529 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 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#[derive(Debug)]
589pub(super) struct BranchPickerOverlay {
590 pub(super) branches: Vec<SiblingBranch>,
592 pub(super) selected: usize,
594 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 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 list.filtering_enabled = false;
799 list.infinite_scrolling = false;
800
801 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 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#[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 pub(super) fn update_from_details(&mut self, details: Option<&Value>) {
878 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 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#[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}