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}