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}
16
17impl Default for ModelSelector {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl ModelSelector {
24    pub fn new() -> Self {
25        Self {
26            visible: false,
27            entries: Vec::new(),
28            filtered: Vec::new(),
29            selected: 0,
30            query: String::new(),
31            current_provider: String::new(),
32            current_model: String::new(),
33        }
34    }
35
36    pub fn open(
37        &mut self,
38        grouped: Vec<(String, Vec<String>)>,
39        current_provider: &str,
40        current_model: &str,
41    ) {
42        self.entries.clear();
43        for (provider, models) in grouped {
44            for model in models {
45                self.entries.push(ModelEntry {
46                    provider: provider.clone(),
47                    model,
48                });
49            }
50        }
51        self.current_provider = current_provider.to_string();
52        self.current_model = current_model.to_string();
53        self.query.clear();
54        self.visible = true;
55        self.apply_filter();
56        if let Some(pos) = self.filtered.iter().position(|&i| {
57            self.entries[i].provider == current_provider && self.entries[i].model == current_model
58        }) {
59            self.selected = pos;
60        }
61    }
62
63    pub fn apply_filter(&mut self) {
64        let q = self.query.to_lowercase();
65        self.filtered = self
66            .entries
67            .iter()
68            .enumerate()
69            .filter(|(_, e)| {
70                if q.is_empty() {
71                    return true;
72                }
73                e.model.to_lowercase().contains(&q) || e.provider.to_lowercase().contains(&q)
74            })
75            .map(|(i, _)| i)
76            .collect();
77        if self.selected >= self.filtered.len() {
78            self.selected = self.filtered.len().saturating_sub(1);
79        }
80    }
81
82    pub fn close(&mut self) {
83        self.visible = false;
84        self.query.clear();
85    }
86
87    pub fn up(&mut self) {
88        if self.selected > 0 {
89            self.selected -= 1;
90        }
91    }
92
93    pub fn down(&mut self) {
94        if self.selected + 1 < self.filtered.len() {
95            self.selected += 1;
96        }
97    }
98
99    pub fn confirm(&mut self) -> Option<ModelEntry> {
100        if self.visible && !self.filtered.is_empty() {
101            self.visible = false;
102            let entry = self.entries[self.filtered[self.selected]].clone();
103            self.query.clear();
104            Some(entry)
105        } else {
106            None
107        }
108    }
109}
110
111#[derive(Clone)]
112pub struct AgentEntry {
113    pub name: String,
114    pub description: String,
115}
116
117pub struct AgentSelector {
118    pub visible: bool,
119    pub entries: Vec<AgentEntry>,
120    pub selected: usize,
121    pub current: String,
122}
123
124impl Default for AgentSelector {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130impl AgentSelector {
131    pub fn new() -> Self {
132        Self {
133            visible: false,
134            entries: Vec::new(),
135            selected: 0,
136            current: String::new(),
137        }
138    }
139
140    pub fn open(&mut self, agents: Vec<AgentEntry>, current: &str) {
141        self.entries = agents;
142        self.current = current.to_string();
143        self.visible = true;
144        self.selected = self
145            .entries
146            .iter()
147            .position(|e| e.name == current)
148            .unwrap_or(0);
149    }
150
151    pub fn close(&mut self) {
152        self.visible = false;
153    }
154
155    pub fn up(&mut self) {
156        if self.selected > 0 {
157            self.selected -= 1;
158        }
159    }
160
161    pub fn down(&mut self) {
162        if self.selected + 1 < self.entries.len() {
163            self.selected += 1;
164        }
165    }
166
167    pub fn confirm(&mut self) -> Option<AgentEntry> {
168        if self.visible && !self.entries.is_empty() {
169            self.visible = false;
170            Some(self.entries[self.selected].clone())
171        } else {
172            None
173        }
174    }
175}
176
177use chrono::{DateTime, Utc};
178
179pub struct SlashCommand {
180    pub name: &'static str,
181    pub aliases: &'static [&'static str],
182    pub description: &'static str,
183    pub shortcut: &'static str,
184}
185
186pub const COMMANDS: &[SlashCommand] = &[
187    SlashCommand {
188        name: "model",
189        aliases: &["m"],
190        description: "switch model",
191        shortcut: "",
192    },
193    SlashCommand {
194        name: "agent",
195        aliases: &["a"],
196        description: "switch agent profile",
197        shortcut: "Tab",
198    },
199    SlashCommand {
200        name: "clear",
201        aliases: &["cl"],
202        description: "clear conversation",
203        shortcut: "",
204    },
205    SlashCommand {
206        name: "help",
207        aliases: &["h"],
208        description: "show commands",
209        shortcut: "",
210    },
211    SlashCommand {
212        name: "thinking",
213        aliases: &["t", "think"],
214        description: "set thinking level",
215        shortcut: "^T",
216    },
217    SlashCommand {
218        name: "sessions",
219        aliases: &["s", "sess"],
220        description: "resume a previous session",
221        shortcut: "",
222    },
223    SlashCommand {
224        name: "new",
225        aliases: &["n"],
226        description: "start new conversation",
227        shortcut: "",
228    },
229];
230
231pub struct CommandPalette {
232    pub visible: bool,
233    pub selected: usize,
234    pub filtered: Vec<usize>,
235}
236
237impl Default for CommandPalette {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl CommandPalette {
244    pub fn new() -> Self {
245        Self {
246            visible: false,
247            selected: 0,
248            filtered: Vec::new(),
249        }
250    }
251
252    pub fn update_filter(&mut self, input: &str) {
253        let query = input.strip_prefix('/').unwrap_or(input).to_lowercase();
254        self.filtered = COMMANDS
255            .iter()
256            .enumerate()
257            .filter(|(_, cmd)| {
258                if query.is_empty() {
259                    return true;
260                }
261                cmd.name.starts_with(&query) || cmd.aliases.iter().any(|a| a.starts_with(&query))
262            })
263            .map(|(i, _)| i)
264            .collect();
265        if self.selected >= self.filtered.len() {
266            self.selected = self.filtered.len().saturating_sub(1);
267        }
268    }
269
270    pub fn open(&mut self, input: &str) {
271        self.visible = true;
272        self.selected = 0;
273        self.update_filter(input);
274    }
275
276    pub fn close(&mut self) {
277        self.visible = false;
278    }
279
280    pub fn up(&mut self) {
281        if self.selected > 0 {
282            self.selected -= 1;
283        }
284    }
285
286    pub fn down(&mut self) {
287        if self.selected + 1 < self.filtered.len() {
288            self.selected += 1;
289        }
290    }
291
292    pub fn confirm(&mut self) -> Option<&'static str> {
293        if self.visible && !self.filtered.is_empty() {
294            self.visible = false;
295            Some(COMMANDS[self.filtered[self.selected]].name)
296        } else {
297            None
298        }
299    }
300}
301
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub enum ThinkingLevel {
304    Off,
305    Low,
306    Medium,
307    High,
308}
309
310impl ThinkingLevel {
311    pub fn budget_tokens(self) -> u32 {
312        match self {
313            ThinkingLevel::Off => 0,
314            ThinkingLevel::Low => 1024,
315            ThinkingLevel::Medium => 8192,
316            ThinkingLevel::High => 32768,
317        }
318    }
319
320    pub fn label(self) -> &'static str {
321        match self {
322            ThinkingLevel::Off => "off",
323            ThinkingLevel::Low => "low",
324            ThinkingLevel::Medium => "medium",
325            ThinkingLevel::High => "high",
326        }
327    }
328
329    pub fn description(self) -> &'static str {
330        match self {
331            ThinkingLevel::Off => "no extended thinking",
332            ThinkingLevel::Low => "1k token budget",
333            ThinkingLevel::Medium => "8k token budget",
334            ThinkingLevel::High => "32k token budget",
335        }
336    }
337
338    pub fn all() -> &'static [ThinkingLevel] {
339        &[
340            ThinkingLevel::Off,
341            ThinkingLevel::Low,
342            ThinkingLevel::Medium,
343            ThinkingLevel::High,
344        ]
345    }
346
347    pub fn from_budget(budget: u32) -> Self {
348        match budget {
349            0 => ThinkingLevel::Off,
350            1..=4095 => ThinkingLevel::Low,
351            4096..=16383 => ThinkingLevel::Medium,
352            _ => ThinkingLevel::High,
353        }
354    }
355
356    pub fn next(self) -> Self {
357        let all = Self::all();
358        let idx = all.iter().position(|l| *l == self).unwrap_or(0);
359        all[(idx + 1) % all.len()]
360    }
361}
362
363pub struct ThinkingSelector {
364    pub visible: bool,
365    pub selected: usize,
366    pub current: ThinkingLevel,
367}
368
369impl Default for ThinkingSelector {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375impl ThinkingSelector {
376    pub fn new() -> Self {
377        Self {
378            visible: false,
379            selected: 0,
380            current: ThinkingLevel::Off,
381        }
382    }
383
384    pub fn open(&mut self, current: ThinkingLevel) {
385        self.current = current;
386        self.selected = ThinkingLevel::all()
387            .iter()
388            .position(|l| *l == current)
389            .unwrap_or(0);
390        self.visible = true;
391    }
392
393    pub fn close(&mut self) {
394        self.visible = false;
395    }
396
397    pub fn up(&mut self) {
398        if self.selected > 0 {
399            self.selected -= 1;
400        }
401    }
402
403    pub fn down(&mut self) {
404        if self.selected + 1 < ThinkingLevel::all().len() {
405            self.selected += 1;
406        }
407    }
408
409    pub fn confirm(&mut self) -> Option<ThinkingLevel> {
410        if self.visible {
411            self.visible = false;
412            Some(ThinkingLevel::all()[self.selected])
413        } else {
414            None
415        }
416    }
417}
418
419#[derive(Clone)]
420pub struct SessionEntry {
421    pub id: String,
422    pub title: String,
423    pub subtitle: String,
424}
425
426pub struct SessionSelector {
427    pub visible: bool,
428    pub entries: Vec<SessionEntry>,
429    pub filtered: Vec<usize>,
430    pub selected: usize,
431    pub query: String,
432}
433
434impl Default for SessionSelector {
435    fn default() -> Self {
436        Self::new()
437    }
438}
439
440impl SessionSelector {
441    pub fn new() -> Self {
442        Self {
443            visible: false,
444            entries: Vec::new(),
445            filtered: Vec::new(),
446            selected: 0,
447            query: String::new(),
448        }
449    }
450
451    pub fn open(&mut self, entries: Vec<SessionEntry>) {
452        self.entries = entries;
453        self.query.clear();
454        self.visible = true;
455        self.selected = 0;
456        self.apply_filter();
457    }
458
459    pub fn apply_filter(&mut self) {
460        let q = self.query.to_lowercase();
461        self.filtered = self
462            .entries
463            .iter()
464            .enumerate()
465            .filter(|(_, e)| {
466                if q.is_empty() {
467                    return true;
468                }
469                e.title.to_lowercase().contains(&q) || e.subtitle.to_lowercase().contains(&q)
470            })
471            .map(|(i, _)| i)
472            .collect();
473        if self.selected >= self.filtered.len() {
474            self.selected = self.filtered.len().saturating_sub(1);
475        }
476    }
477
478    pub fn close(&mut self) {
479        self.visible = false;
480        self.query.clear();
481    }
482
483    pub fn up(&mut self) {
484        if self.selected > 0 {
485            self.selected -= 1;
486        }
487    }
488
489    pub fn down(&mut self) {
490        if self.selected + 1 < self.filtered.len() {
491            self.selected += 1;
492        }
493    }
494
495    pub fn confirm(&mut self) -> Option<String> {
496        if self.visible && !self.filtered.is_empty() {
497            self.visible = false;
498            let id = self.entries[self.filtered[self.selected]].id.clone();
499            self.query.clear();
500            Some(id)
501        } else {
502            None
503        }
504    }
505}
506
507pub struct HelpPopup {
508    pub visible: bool,
509}
510
511impl Default for HelpPopup {
512    fn default() -> Self {
513        Self::new()
514    }
515}
516
517impl HelpPopup {
518    pub fn new() -> Self {
519        Self { visible: false }
520    }
521
522    pub fn open(&mut self) {
523        self.visible = true;
524    }
525
526    pub fn close(&mut self) {
527        self.visible = false;
528    }
529}
530
531pub fn time_ago(iso: &str) -> String {
532    if let Ok(dt) = iso.parse::<DateTime<Utc>>() {
533        let secs = Utc::now().signed_duration_since(dt).num_seconds();
534        if secs < 60 {
535            return "just now".to_string();
536        }
537        if secs < 3600 {
538            return format!("{}m ago", secs / 60);
539        }
540        if secs < 86400 {
541            return format!("{}h ago", secs / 3600);
542        }
543        if secs < 604800 {
544            return format!("{}d ago", secs / 86400);
545        }
546        return format!("{}w ago", secs / 604800);
547    }
548    iso.to_string()
549}
550
551pub struct MessageContextMenu {
552    pub visible: bool,
553    pub message_index: usize,
554    pub selected: usize,
555    pub screen_x: u16,
556    pub screen_y: u16,
557}
558
559impl Default for MessageContextMenu {
560    fn default() -> Self {
561        Self::new()
562    }
563}
564
565impl MessageContextMenu {
566    pub fn new() -> Self {
567        Self {
568            visible: false,
569            message_index: 0,
570            selected: 0,
571            screen_x: 0,
572            screen_y: 0,
573        }
574    }
575
576    pub fn open(&mut self, message_index: usize, x: u16, y: u16) {
577        self.visible = true;
578        self.message_index = message_index;
579        self.selected = 0;
580        self.screen_x = x;
581        self.screen_y = y;
582    }
583
584    pub fn close(&mut self) {
585        self.visible = false;
586    }
587
588    pub fn up(&mut self) {
589        if self.selected > 0 {
590            self.selected -= 1;
591        }
592    }
593
594    pub fn down(&mut self) {
595        if self.selected < 1 {
596            self.selected += 1;
597        }
598    }
599
600    pub fn confirm(&mut self) -> Option<(usize, usize)> {
601        if self.visible {
602            self.visible = false;
603            Some((self.selected, self.message_index))
604        } else {
605            None
606        }
607    }
608
609    pub fn labels() -> &'static [&'static str] {
610        &["continue from here", "fork from here"]
611    }
612}