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}