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 < Self::labels().len() - 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 &["revert to message", "fork from here", "copy"]
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}