Skip to main content

dot/tui/
widgets.rs

1#[derive(Clone)]
2pub struct ModelEntry {
3    pub provider: String,
4    pub model: String,
5}
6
7pub struct ModelSelector {
8    pub visible: bool,
9    pub entries: Vec<ModelEntry>,
10    pub filtered: Vec<usize>,
11    pub selected: usize,
12    pub query: String,
13    pub current_provider: String,
14    pub current_model: String,
15    pub favorites: Vec<String>,
16}
17
18impl Default for ModelSelector {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl ModelSelector {
25    pub fn new() -> Self {
26        Self {
27            visible: false,
28            entries: Vec::new(),
29            filtered: Vec::new(),
30            selected: 0,
31            query: String::new(),
32            current_provider: String::new(),
33            current_model: String::new(),
34            favorites: Vec::new(),
35        }
36    }
37
38    pub fn open(
39        &mut self,
40        grouped: Vec<(String, Vec<String>)>,
41        current_provider: &str,
42        current_model: &str,
43    ) {
44        self.entries.clear();
45        for (provider, models) in grouped {
46            for model in models {
47                self.entries.push(ModelEntry {
48                    provider: provider.clone(),
49                    model,
50                });
51            }
52        }
53        self.current_provider = current_provider.to_string();
54        self.current_model = current_model.to_string();
55        self.query.clear();
56        self.visible = true;
57        self.apply_filter();
58        if let Some(pos) = self.filtered.iter().position(|&i| {
59            self.entries[i].provider == current_provider && self.entries[i].model == current_model
60        }) {
61            self.selected = pos;
62        }
63    }
64
65    pub fn toggle_favorite(&mut self) -> Option<String> {
66        let idx = *self.filtered.get(self.selected)?;
67        let model = self.entries[idx].model.clone();
68        if let Some(pos) = self.favorites.iter().position(|f| f == &model) {
69            self.favorites.remove(pos);
70        } else {
71            self.favorites.push(model.clone());
72        }
73        Some(model)
74    }
75
76    pub fn apply_filter(&mut self) {
77        let q = self.query.to_lowercase();
78        self.filtered = self
79            .entries
80            .iter()
81            .enumerate()
82            .filter(|(_, e)| {
83                if q.is_empty() {
84                    return true;
85                }
86                e.model.to_lowercase().contains(&q) || e.provider.to_lowercase().contains(&q)
87            })
88            .map(|(i, _)| i)
89            .collect();
90        self.filtered.sort_by(|&a, &b| {
91            let a_fav = self.favorites.contains(&self.entries[a].model);
92            let b_fav = self.favorites.contains(&self.entries[b].model);
93            b_fav.cmp(&a_fav)
94        });
95        if self.selected >= self.filtered.len() {
96            self.selected = self.filtered.len().saturating_sub(1);
97        }
98    }
99
100    pub fn close(&mut self) {
101        self.visible = false;
102        self.query.clear();
103    }
104
105    pub fn up(&mut self) {
106        if self.selected > 0 {
107            self.selected -= 1;
108        }
109    }
110
111    pub fn down(&mut self) {
112        if self.selected + 1 < self.filtered.len() {
113            self.selected += 1;
114        }
115    }
116
117    pub fn confirm(&mut self) -> Option<ModelEntry> {
118        if self.visible && !self.filtered.is_empty() {
119            self.visible = false;
120            let entry = self.entries[self.filtered[self.selected]].clone();
121            self.query.clear();
122            Some(entry)
123        } else {
124            None
125        }
126    }
127}
128
129#[derive(Clone)]
130pub struct AgentEntry {
131    pub name: String,
132    pub description: String,
133}
134
135pub struct AgentSelector {
136    pub visible: bool,
137    pub entries: Vec<AgentEntry>,
138    pub selected: usize,
139    pub current: String,
140}
141
142impl Default for AgentSelector {
143    fn default() -> Self {
144        Self::new()
145    }
146}
147
148impl AgentSelector {
149    pub fn new() -> Self {
150        Self {
151            visible: false,
152            entries: Vec::new(),
153            selected: 0,
154            current: String::new(),
155        }
156    }
157
158    pub fn open(&mut self, agents: Vec<AgentEntry>, current: &str) {
159        self.entries = agents;
160        self.current = current.to_string();
161        self.visible = true;
162        self.selected = self
163            .entries
164            .iter()
165            .position(|e| e.name == current)
166            .unwrap_or(0);
167    }
168
169    pub fn close(&mut self) {
170        self.visible = false;
171    }
172
173    pub fn up(&mut self) {
174        if self.selected > 0 {
175            self.selected -= 1;
176        }
177    }
178
179    pub fn down(&mut self) {
180        if self.selected + 1 < self.entries.len() {
181            self.selected += 1;
182        }
183    }
184
185    pub fn confirm(&mut self) -> Option<AgentEntry> {
186        if self.visible && !self.entries.is_empty() {
187            self.visible = false;
188            Some(self.entries[self.selected].clone())
189        } else {
190            None
191        }
192    }
193}
194
195use chrono::{DateTime, Utc};
196
197pub struct SlashCommand {
198    pub name: &'static str,
199    pub aliases: &'static [&'static str],
200    pub description: &'static str,
201    pub shortcut: &'static str,
202}
203
204pub const COMMANDS: &[SlashCommand] = &[
205    SlashCommand {
206        name: "model",
207        aliases: &["m"],
208        description: "switch model",
209        shortcut: "",
210    },
211    SlashCommand {
212        name: "agent",
213        aliases: &["a"],
214        description: "switch agent profile",
215        shortcut: "Tab",
216    },
217    SlashCommand {
218        name: "clear",
219        aliases: &["cl"],
220        description: "clear conversation",
221        shortcut: "",
222    },
223    SlashCommand {
224        name: "help",
225        aliases: &["h"],
226        description: "show commands",
227        shortcut: "",
228    },
229    SlashCommand {
230        name: "thinking",
231        aliases: &["t", "think"],
232        description: "set thinking level",
233        shortcut: "^T",
234    },
235    SlashCommand {
236        name: "sessions",
237        aliases: &["s", "sess"],
238        description: "resume a previous session",
239        shortcut: "",
240    },
241    SlashCommand {
242        name: "new",
243        aliases: &["n"],
244        description: "start new conversation",
245        shortcut: "",
246    },
247    SlashCommand {
248        name: "rename",
249        aliases: &["r"],
250        description: "rename this session",
251        shortcut: "^R",
252    },
253    SlashCommand {
254        name: "export",
255        aliases: &["e"],
256        description: "export session to markdown",
257        shortcut: "",
258    },
259];
260#[derive(Debug, Clone, PartialEq)]
261pub enum PaletteEntryKind {
262    Command,
263    Skill,
264}
265
266#[derive(Debug, Clone)]
267pub struct PaletteEntry {
268    pub name: String,
269    pub description: String,
270    pub shortcut: String,
271    pub kind: PaletteEntryKind,
272}
273
274pub struct CommandPalette {
275    pub visible: bool,
276    pub selected: usize,
277    pub filtered: Vec<usize>,
278    pub entries: Vec<PaletteEntry>,
279}
280
281impl Default for CommandPalette {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl CommandPalette {
288    pub fn new() -> Self {
289        Self {
290            visible: false,
291            selected: 0,
292            filtered: Vec::new(),
293            entries: Vec::new(),
294        }
295    }
296
297    pub fn set_skills(&mut self, skills: &[(String, String)]) {
298        self.entries.clear();
299        for cmd in COMMANDS {
300            self.entries.push(PaletteEntry {
301                name: cmd.name.to_string(),
302                description: cmd.description.to_string(),
303                shortcut: cmd.shortcut.to_string(),
304                kind: PaletteEntryKind::Command,
305            });
306        }
307        for (name, desc) in skills {
308            self.entries.push(PaletteEntry {
309                name: name.clone(),
310                description: desc.clone(),
311                shortcut: String::new(),
312                kind: PaletteEntryKind::Skill,
313            });
314        }
315    }
316
317    pub fn add_custom_commands(&mut self, commands: &[(&str, &str)]) {
318        for (name, desc) in commands {
319            self.entries.push(PaletteEntry {
320                name: name.to_string(),
321                description: desc.to_string(),
322                shortcut: String::new(),
323                kind: PaletteEntryKind::Command,
324            });
325        }
326    }
327
328    pub fn update_filter(&mut self, input: &str) {
329        if self.entries.is_empty() {
330            for cmd in COMMANDS {
331                self.entries.push(PaletteEntry {
332                    name: cmd.name.to_string(),
333                    description: cmd.description.to_string(),
334                    shortcut: cmd.shortcut.to_string(),
335                    kind: PaletteEntryKind::Command,
336                });
337            }
338        }
339        let query = input.strip_prefix('/').unwrap_or(input).to_lowercase();
340        self.filtered = self
341            .entries
342            .iter()
343            .enumerate()
344            .filter(|(_, e)| {
345                if query.is_empty() {
346                    return true;
347                }
348                e.name.to_lowercase().starts_with(&query)
349                    || e.description.to_lowercase().contains(&query)
350            })
351            .map(|(i, _)| i)
352            .collect();
353        if self.selected >= self.filtered.len() {
354            self.selected = self.filtered.len().saturating_sub(1);
355        }
356    }
357
358    pub fn open(&mut self, input: &str) {
359        self.visible = true;
360        self.selected = 0;
361        self.update_filter(input);
362    }
363
364    pub fn close(&mut self) {
365        self.visible = false;
366    }
367
368    pub fn up(&mut self) {
369        if self.selected > 0 {
370            self.selected -= 1;
371        }
372    }
373
374    pub fn down(&mut self) {
375        if self.selected + 1 < self.filtered.len() {
376            self.selected += 1;
377        }
378    }
379
380    pub fn confirm(&mut self) -> Option<PaletteEntry> {
381        if self.visible && !self.filtered.is_empty() {
382            self.visible = false;
383            Some(self.entries[self.filtered[self.selected]].clone())
384        } else {
385            None
386        }
387    }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq)]
391pub enum ThinkingLevel {
392    Off,
393    Low,
394    Medium,
395    High,
396}
397
398impl ThinkingLevel {
399    pub fn budget_tokens(self) -> u32 {
400        match self {
401            ThinkingLevel::Off => 0,
402            ThinkingLevel::Low => 1024,
403            ThinkingLevel::Medium => 8192,
404            ThinkingLevel::High => 32768,
405        }
406    }
407
408    pub fn label(self) -> &'static str {
409        match self {
410            ThinkingLevel::Off => "off",
411            ThinkingLevel::Low => "low",
412            ThinkingLevel::Medium => "medium",
413            ThinkingLevel::High => "high",
414        }
415    }
416
417    pub fn description(self) -> &'static str {
418        match self {
419            ThinkingLevel::Off => "no extended thinking",
420            ThinkingLevel::Low => "1k token budget",
421            ThinkingLevel::Medium => "8k token budget",
422            ThinkingLevel::High => "32k token budget",
423        }
424    }
425
426    pub fn all() -> &'static [ThinkingLevel] {
427        &[
428            ThinkingLevel::Off,
429            ThinkingLevel::Low,
430            ThinkingLevel::Medium,
431            ThinkingLevel::High,
432        ]
433    }
434
435    pub fn from_budget(budget: u32) -> Self {
436        match budget {
437            0 => ThinkingLevel::Off,
438            1..=4095 => ThinkingLevel::Low,
439            4096..=16383 => ThinkingLevel::Medium,
440            _ => ThinkingLevel::High,
441        }
442    }
443
444    pub fn next(self) -> Self {
445        let all = Self::all();
446        let idx = all.iter().position(|l| *l == self).unwrap_or(0);
447        all[(idx + 1) % all.len()]
448    }
449}
450
451pub struct ThinkingSelector {
452    pub visible: bool,
453    pub selected: usize,
454    pub current: ThinkingLevel,
455}
456
457impl Default for ThinkingSelector {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463impl ThinkingSelector {
464    pub fn new() -> Self {
465        Self {
466            visible: false,
467            selected: 0,
468            current: ThinkingLevel::Off,
469        }
470    }
471
472    pub fn open(&mut self, current: ThinkingLevel) {
473        self.current = current;
474        self.selected = ThinkingLevel::all()
475            .iter()
476            .position(|l| *l == current)
477            .unwrap_or(0);
478        self.visible = true;
479    }
480
481    pub fn close(&mut self) {
482        self.visible = false;
483    }
484
485    pub fn up(&mut self) {
486        if self.selected > 0 {
487            self.selected -= 1;
488        }
489    }
490
491    pub fn down(&mut self) {
492        if self.selected + 1 < ThinkingLevel::all().len() {
493            self.selected += 1;
494        }
495    }
496
497    pub fn confirm(&mut self) -> Option<ThinkingLevel> {
498        if self.visible {
499            self.visible = false;
500            Some(ThinkingLevel::all()[self.selected])
501        } else {
502            None
503        }
504    }
505}
506
507#[derive(Clone)]
508pub struct SessionEntry {
509    pub id: String,
510    pub title: String,
511    pub subtitle: String,
512}
513
514pub struct SessionSelector {
515    pub visible: bool,
516    pub entries: Vec<SessionEntry>,
517    pub filtered: Vec<usize>,
518    pub selected: usize,
519    pub query: String,
520}
521
522impl Default for SessionSelector {
523    fn default() -> Self {
524        Self::new()
525    }
526}
527
528impl SessionSelector {
529    pub fn new() -> Self {
530        Self {
531            visible: false,
532            entries: Vec::new(),
533            filtered: Vec::new(),
534            selected: 0,
535            query: String::new(),
536        }
537    }
538
539    pub fn open(&mut self, entries: Vec<SessionEntry>) {
540        self.entries = entries;
541        self.query.clear();
542        self.visible = true;
543        self.selected = 0;
544        self.apply_filter();
545    }
546
547    pub fn apply_filter(&mut self) {
548        let q = self.query.to_lowercase();
549        self.filtered = self
550            .entries
551            .iter()
552            .enumerate()
553            .filter(|(_, e)| {
554                if q.is_empty() {
555                    return true;
556                }
557                e.title.to_lowercase().contains(&q) || e.subtitle.to_lowercase().contains(&q)
558            })
559            .map(|(i, _)| i)
560            .collect();
561        if self.selected >= self.filtered.len() {
562            self.selected = self.filtered.len().saturating_sub(1);
563        }
564    }
565
566    pub fn close(&mut self) {
567        self.visible = false;
568        self.query.clear();
569    }
570
571    pub fn up(&mut self) {
572        if self.selected > 0 {
573            self.selected -= 1;
574        }
575    }
576
577    pub fn down(&mut self) {
578        if self.selected + 1 < self.filtered.len() {
579            self.selected += 1;
580        }
581    }
582
583    pub fn confirm(&mut self) -> Option<String> {
584        if self.visible && !self.filtered.is_empty() {
585            self.visible = false;
586            let id = self.entries[self.filtered[self.selected]].id.clone();
587            self.query.clear();
588            Some(id)
589        } else {
590            None
591        }
592    }
593}
594
595pub struct HelpPopup {
596    pub visible: bool,
597}
598
599impl Default for HelpPopup {
600    fn default() -> Self {
601        Self::new()
602    }
603}
604
605impl HelpPopup {
606    pub fn new() -> Self {
607        Self { visible: false }
608    }
609
610    pub fn open(&mut self) {
611        self.visible = true;
612    }
613
614    pub fn close(&mut self) {
615        self.visible = false;
616    }
617}
618
619pub fn time_ago(iso: &str) -> String {
620    if let Ok(dt) = iso.parse::<DateTime<Utc>>() {
621        let secs = Utc::now().signed_duration_since(dt).num_seconds();
622        if secs < 60 {
623            return "just now".to_string();
624        }
625        if secs < 3600 {
626            return format!("{}m ago", secs / 60);
627        }
628        if secs < 86400 {
629            return format!("{}h ago", secs / 3600);
630        }
631        if secs < 604800 {
632            return format!("{}d ago", secs / 86400);
633        }
634        return format!("{}w ago", secs / 604800);
635    }
636    iso.to_string()
637}
638
639pub struct MessageContextMenu {
640    pub visible: bool,
641    pub message_index: usize,
642    pub selected: usize,
643    pub screen_x: u16,
644    pub screen_y: u16,
645}
646
647impl Default for MessageContextMenu {
648    fn default() -> Self {
649        Self::new()
650    }
651}
652
653impl MessageContextMenu {
654    pub fn new() -> Self {
655        Self {
656            visible: false,
657            message_index: 0,
658            selected: 0,
659            screen_x: 0,
660            screen_y: 0,
661        }
662    }
663
664    pub fn open(&mut self, message_index: usize, x: u16, y: u16) {
665        self.visible = true;
666        self.message_index = message_index;
667        self.selected = 0;
668        self.screen_x = x;
669        self.screen_y = y;
670    }
671
672    pub fn close(&mut self) {
673        self.visible = false;
674    }
675
676    pub fn up(&mut self) {
677        if self.selected > 0 {
678            self.selected -= 1;
679        }
680    }
681
682    pub fn down(&mut self) {
683        if self.selected < 1 {
684            self.selected += 1;
685        }
686    }
687
688    pub fn confirm(&mut self) -> Option<(usize, usize)> {
689        if self.visible {
690            self.visible = false;
691            Some((self.selected, self.message_index))
692        } else {
693            None
694        }
695    }
696
697    pub fn labels() -> &'static [&'static str] {
698        &["continue from here", "fork from here"]
699    }
700}
701
702#[derive(Clone)]
703pub struct FilePickerEntry {
704    pub name: String,
705    pub path: String,
706    pub is_dir: bool,
707}
708
709pub struct FilePicker {
710    pub visible: bool,
711    pub entries: Vec<FilePickerEntry>,
712    pub filtered: Vec<usize>,
713    pub selected: usize,
714    pub query: String,
715    pub at_pos: usize,
716    base_dir: String,
717}
718
719impl Default for FilePicker {
720    fn default() -> Self {
721        Self::new()
722    }
723}
724
725impl FilePicker {
726    pub fn new() -> Self {
727        Self {
728            visible: false,
729            entries: Vec::new(),
730            filtered: Vec::new(),
731            selected: 0,
732            query: String::new(),
733            at_pos: 0,
734            base_dir: String::new(),
735        }
736    }
737
738    pub fn open(&mut self, at_pos: usize) {
739        self.visible = true;
740        self.at_pos = at_pos;
741        self.query.clear();
742        self.selected = 0;
743        self.base_dir.clear();
744        self.populate();
745    }
746
747    pub fn close(&mut self) {
748        self.visible = false;
749        self.query.clear();
750        self.entries.clear();
751        self.filtered.clear();
752    }
753
754    pub fn populate(&mut self) {
755        let (dir, _) = self.dir_and_filter();
756        self.base_dir = dir.clone();
757        self.entries.clear();
758
759        let read_path = if dir.is_empty() {
760            ".".to_string()
761        } else {
762            dir.clone()
763        };
764        let Ok(rd) = std::fs::read_dir(&read_path) else {
765            return;
766        };
767
768        let mut dirs = Vec::new();
769        let mut files = Vec::new();
770
771        for entry in rd.flatten() {
772            let name = entry.file_name().to_string_lossy().to_string();
773            if name.starts_with('.') {
774                continue;
775            }
776            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
777            let rel = if dir.is_empty() {
778                name.clone()
779            } else {
780                format!("{}{}", dir, name)
781            };
782            let e = FilePickerEntry {
783                name,
784                path: rel,
785                is_dir,
786            };
787            if is_dir {
788                dirs.push(e);
789            } else {
790                files.push(e);
791            }
792        }
793
794        dirs.sort_by(|a, b| a.name.cmp(&b.name));
795        files.sort_by(|a, b| a.name.cmp(&b.name));
796        self.entries.extend(dirs);
797        self.entries.extend(files);
798        self.apply_filter();
799    }
800
801    fn dir_and_filter(&self) -> (String, String) {
802        if let Some(pos) = self.query.rfind('/') {
803            (
804                self.query[..=pos].to_string(),
805                self.query[pos + 1..].to_string(),
806            )
807        } else {
808            (String::new(), self.query.clone())
809        }
810    }
811
812    pub fn apply_filter(&mut self) {
813        let (_, filter) = self.dir_and_filter();
814        let q = filter.to_lowercase();
815        self.filtered = self
816            .entries
817            .iter()
818            .enumerate()
819            .filter(|(_, e)| {
820                if q.is_empty() {
821                    return true;
822                }
823                e.name.to_lowercase().starts_with(&q) || e.name.to_lowercase().contains(&q)
824            })
825            .map(|(i, _)| i)
826            .collect();
827        if self.selected >= self.filtered.len() {
828            self.selected = self.filtered.len().saturating_sub(1);
829        }
830    }
831
832    pub fn update_query(&mut self, query: &str) {
833        let (old_dir, _) = self.dir_and_filter();
834        self.query = query.to_string();
835        let (new_dir, _) = self.dir_and_filter();
836        if new_dir != old_dir {
837            self.populate();
838        } else {
839            self.apply_filter();
840        }
841    }
842
843    pub fn up(&mut self) {
844        if self.selected > 0 {
845            self.selected -= 1;
846        }
847    }
848
849    pub fn down(&mut self) {
850        if self.selected + 1 < self.filtered.len() {
851            self.selected += 1;
852        }
853    }
854
855    pub fn confirm(&mut self) -> Option<FilePickerEntry> {
856        if self.visible && !self.filtered.is_empty() {
857            self.visible = false;
858            let entry = self.entries[self.filtered[self.selected]].clone();
859            self.query.clear();
860            Some(entry)
861        } else {
862            None
863        }
864    }
865}