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
357pub struct ThinkingSelector {
358    pub visible: bool,
359    pub selected: usize,
360    pub current: ThinkingLevel,
361}
362
363impl Default for ThinkingSelector {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369impl ThinkingSelector {
370    pub fn new() -> Self {
371        Self {
372            visible: false,
373            selected: 0,
374            current: ThinkingLevel::Off,
375        }
376    }
377
378    pub fn open(&mut self, current: ThinkingLevel) {
379        self.current = current;
380        self.selected = ThinkingLevel::all()
381            .iter()
382            .position(|l| *l == current)
383            .unwrap_or(0);
384        self.visible = true;
385    }
386
387    pub fn close(&mut self) {
388        self.visible = false;
389    }
390
391    pub fn up(&mut self) {
392        if self.selected > 0 {
393            self.selected -= 1;
394        }
395    }
396
397    pub fn down(&mut self) {
398        if self.selected + 1 < ThinkingLevel::all().len() {
399            self.selected += 1;
400        }
401    }
402
403    pub fn confirm(&mut self) -> Option<ThinkingLevel> {
404        if self.visible {
405            self.visible = false;
406            Some(ThinkingLevel::all()[self.selected])
407        } else {
408            None
409        }
410    }
411}
412
413#[derive(Clone)]
414pub struct SessionEntry {
415    pub id: String,
416    pub title: String,
417    pub subtitle: String,
418}
419
420pub struct SessionSelector {
421    pub visible: bool,
422    pub entries: Vec<SessionEntry>,
423    pub filtered: Vec<usize>,
424    pub selected: usize,
425    pub query: String,
426}
427
428impl Default for SessionSelector {
429    fn default() -> Self {
430        Self::new()
431    }
432}
433
434impl SessionSelector {
435    pub fn new() -> Self {
436        Self {
437            visible: false,
438            entries: Vec::new(),
439            filtered: Vec::new(),
440            selected: 0,
441            query: String::new(),
442        }
443    }
444
445    pub fn open(&mut self, entries: Vec<SessionEntry>) {
446        self.entries = entries;
447        self.query.clear();
448        self.visible = true;
449        self.selected = 0;
450        self.apply_filter();
451    }
452
453    pub fn apply_filter(&mut self) {
454        let q = self.query.to_lowercase();
455        self.filtered = self
456            .entries
457            .iter()
458            .enumerate()
459            .filter(|(_, e)| {
460                if q.is_empty() {
461                    return true;
462                }
463                e.title.to_lowercase().contains(&q) || e.subtitle.to_lowercase().contains(&q)
464            })
465            .map(|(i, _)| i)
466            .collect();
467        if self.selected >= self.filtered.len() {
468            self.selected = self.filtered.len().saturating_sub(1);
469        }
470    }
471
472    pub fn close(&mut self) {
473        self.visible = false;
474        self.query.clear();
475    }
476
477    pub fn up(&mut self) {
478        if self.selected > 0 {
479            self.selected -= 1;
480        }
481    }
482
483    pub fn down(&mut self) {
484        if self.selected + 1 < self.filtered.len() {
485            self.selected += 1;
486        }
487    }
488
489    pub fn confirm(&mut self) -> Option<String> {
490        if self.visible && !self.filtered.is_empty() {
491            self.visible = false;
492            let id = self.entries[self.filtered[self.selected]].id.clone();
493            self.query.clear();
494            Some(id)
495        } else {
496            None
497        }
498    }
499}
500
501pub struct HelpPopup {
502    pub visible: bool,
503}
504
505impl Default for HelpPopup {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511impl HelpPopup {
512    pub fn new() -> Self {
513        Self { visible: false }
514    }
515
516    pub fn open(&mut self) {
517        self.visible = true;
518    }
519
520    pub fn close(&mut self) {
521        self.visible = false;
522    }
523}
524
525pub fn time_ago(iso: &str) -> String {
526    if let Ok(dt) = iso.parse::<DateTime<Utc>>() {
527        let secs = Utc::now().signed_duration_since(dt).num_seconds();
528        if secs < 60 {
529            return "just now".to_string();
530        }
531        if secs < 3600 {
532            return format!("{}m ago", secs / 60);
533        }
534        if secs < 86400 {
535            return format!("{}h ago", secs / 3600);
536        }
537        if secs < 604800 {
538            return format!("{}d ago", secs / 86400);
539        }
540        return format!("{}w ago", secs / 604800);
541    }
542    iso.to_string()
543}