cobolx 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/tui.rs ADDED
@@ -0,0 +1,812 @@
1
+ use crate::agent::client::AgentRouter;
2
+ use crate::ui::draw;
3
+ use chrono::Local;
4
+ use crossterm::{
5
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
6
+ execute,
7
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
8
+ };
9
+ use ratatui::{Terminal, backend::CrosstermBackend};
10
+ use std::io;
11
+ use std::sync::Arc;
12
+
13
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
+ pub enum Sender {
15
+ User,
16
+ Cobolx,
17
+ }
18
+
19
+ #[derive(Debug, Clone)]
20
+ pub struct Message {
21
+ pub sender: Sender,
22
+ pub text: String,
23
+ pub timestamp: String,
24
+ }
25
+
26
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
+ pub enum RoutingMode {
28
+ Auto,
29
+ ForceLight,
30
+ ForceHeavy,
31
+ }
32
+
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+ pub enum ViewMode {
35
+ Chat,
36
+ Config,
37
+ SandboxSelect,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41
+ pub enum DropdownType {
42
+ None,
43
+ Commands,
44
+ Files,
45
+ }
46
+
47
+ pub enum TaskUpdate {
48
+ Routed(crate::agent::client::Route, &'static str),
49
+ Delta(String, &'static str),
50
+ Status(String),
51
+ Finished(
52
+ Result<Option<crate::agent::client::Usage>, String>,
53
+ &'static str,
54
+ ),
55
+ }
56
+
57
+ pub struct App {
58
+ pub messages: Vec<Message>,
59
+ pub input_text: String,
60
+ pub dropdown_index: usize,
61
+ pub show_dropdown: bool,
62
+ pub routing_mode: RoutingMode,
63
+ pub router: Arc<AgentRouter>,
64
+ pub view_mode: ViewMode,
65
+ pub config_active_field: usize,
66
+ pub config_deepseek_input: String,
67
+ pub config_glm_input: String,
68
+ pub last_model: Option<String>,
69
+ pub last_prompt_tokens: u32,
70
+ pub last_completion_tokens: u32,
71
+ pub deepseek_prompt_tokens: u32,
72
+ pub deepseek_completion_tokens: u32,
73
+ pub glm_prompt_tokens: u32,
74
+ pub glm_completion_tokens: u32,
75
+ pub sandbox_active_option: usize,
76
+ pub sandbox_path: Option<std::path::PathBuf>,
77
+ pub discovered_files: Vec<crate::cobol::scanner::CobolFileEntry>,
78
+ pub active_agent: Option<String>,
79
+ pub agent_status: Option<String>,
80
+ pub spinner_tick: usize,
81
+ }
82
+
83
+ impl App {
84
+ pub fn new() -> Self {
85
+ let router = Arc::new(AgentRouter::new());
86
+ let mut messages = vec![Message {
87
+ sender: Sender::Cobolx,
88
+ text: "Hello! Welcome to the COBOLX console. Type your message below and press Enter to interact.".to_string(),
89
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
90
+ }];
91
+
92
+ let (_, config_data) = crate::config::ConfigManager::load_or_create();
93
+ let has_keys = router.has_credentials();
94
+
95
+ let view_mode = if !has_keys {
96
+ ViewMode::Config
97
+ } else {
98
+ ViewMode::SandboxSelect
99
+ };
100
+
101
+ if !has_keys {
102
+ let path_msg = if let Some(ref path) = router.config_path {
103
+ format!(
104
+ "Please configure your API keys in the configuration file:\n {}\nOr input them below directly in this screen.",
105
+ path
106
+ )
107
+ } else {
108
+ "Please enter your API keys below.".to_string()
109
+ };
110
+ messages.push(Message {
111
+ sender: Sender::Cobolx,
112
+ text: format!("WARNING: No API credentials found!\n{}", path_msg),
113
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
114
+ });
115
+ }
116
+
117
+ App {
118
+ messages,
119
+ input_text: String::new(),
120
+ dropdown_index: 0,
121
+ show_dropdown: false,
122
+ routing_mode: RoutingMode::Auto,
123
+ router,
124
+ view_mode,
125
+ config_active_field: 0,
126
+ config_deepseek_input: config_data.deepseek_api_key,
127
+ config_glm_input: config_data.glm_api_key,
128
+ last_model: None,
129
+ last_prompt_tokens: 0,
130
+ last_completion_tokens: 0,
131
+ deepseek_prompt_tokens: 0,
132
+ deepseek_completion_tokens: 0,
133
+ glm_prompt_tokens: 0,
134
+ glm_completion_tokens: 0,
135
+ sandbox_active_option: 0,
136
+ sandbox_path: None,
137
+ discovered_files: Vec::new(),
138
+ active_agent: None,
139
+ agent_status: None,
140
+ spinner_tick: 0,
141
+ }
142
+ }
143
+
144
+ pub fn get_filtered_commands(&self) -> Vec<String> {
145
+ let commands = vec![
146
+ "/help".to_string(),
147
+ "/clear".to_string(),
148
+ "/about".to_string(),
149
+ "/model".to_string(),
150
+ "/config".to_string(),
151
+ "/tokens".to_string(),
152
+ "/init".to_string(),
153
+ "/exit".to_string(),
154
+ ];
155
+ if !self.input_text.starts_with('/') {
156
+ return Vec::new();
157
+ }
158
+ commands
159
+ .into_iter()
160
+ .filter(|c| c.starts_with(&self.input_text))
161
+ .collect()
162
+ }
163
+
164
+ pub fn get_at_query(&self) -> Option<&str> {
165
+ if let Some(idx) = self.input_text.rfind('@') {
166
+ let suffix = &self.input_text[idx + 1..];
167
+ if !suffix.contains(' ') {
168
+ return Some(suffix);
169
+ }
170
+ }
171
+ None
172
+ }
173
+
174
+ pub fn get_filtered_files(&self) -> Vec<String> {
175
+ let Some(query) = self.get_at_query() else {
176
+ return Vec::new();
177
+ };
178
+ let query_lower = query.to_lowercase();
179
+ let mut list = Vec::new();
180
+ for entry in &self.discovered_files {
181
+ if let Some(file_name) = entry.path.file_name().and_then(|s| s.to_str()) {
182
+ let file_name_lower = file_name.to_lowercase();
183
+ if query_lower.is_empty() || file_name_lower.contains(&query_lower) {
184
+ list.push(file_name.to_string());
185
+ }
186
+ }
187
+ }
188
+ list.sort();
189
+ list.dedup();
190
+ list
191
+ }
192
+
193
+ pub fn get_dropdown_type(&self) -> DropdownType {
194
+ if self.input_text.starts_with('/') {
195
+ let filtered = self.get_filtered_commands();
196
+ if !filtered.is_empty() {
197
+ return DropdownType::Commands;
198
+ }
199
+ }
200
+ if self.get_at_query().is_some() {
201
+ let filtered = self.get_filtered_files();
202
+ if !filtered.is_empty() {
203
+ return DropdownType::Files;
204
+ }
205
+ }
206
+ DropdownType::None
207
+ }
208
+
209
+ pub fn insert_selected_file(&mut self, file_name: &str) {
210
+ if let Some(idx) = self.input_text.rfind('@') {
211
+ let mut new_text = self.input_text[..idx].to_string();
212
+ new_text.push('@');
213
+ new_text.push_str(file_name);
214
+ new_text.push(' ');
215
+ self.input_text = new_text;
216
+ }
217
+ }
218
+
219
+ /// Submits active input. Returns (should_exit, is_command)
220
+ pub fn submit_message(&mut self) -> (bool, bool) {
221
+ let text = self.input_text.trim().to_string();
222
+ if text.is_empty() {
223
+ return (false, false);
224
+ }
225
+
226
+ // Add user message
227
+ self.messages.push(Message {
228
+ sender: Sender::User,
229
+ text: text.clone(),
230
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
231
+ });
232
+
233
+ let mut should_exit = false;
234
+ let mut is_command = false;
235
+
236
+ // Parse command if it starts with '/'
237
+ if text.starts_with('/') {
238
+ is_command = true;
239
+ let parts: Vec<&str> = text.split_whitespace().collect();
240
+ if !parts.is_empty() {
241
+ let cmd = parts[0][1..].to_lowercase();
242
+ match cmd.as_str() {
243
+ "exit" | "quit" => {
244
+ should_exit = true;
245
+ }
246
+ "clear" => {
247
+ self.messages.clear();
248
+ self.messages.push(Message {
249
+ sender: Sender::Cobolx,
250
+ text: "Console history cleared. Ready for next prompt.".to_string(),
251
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
252
+ });
253
+ }
254
+ "about" => {
255
+ self.messages.push(Message {
256
+ sender: Sender::Cobolx,
257
+ text: "COBOLX Console v1.0.0. A high-performance terminal chat interface designed for AI agents, styled after Spring Boot.".to_string(),
258
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
259
+ });
260
+ }
261
+ "help" => {
262
+ let help_message = "Available Commands:\n\
263
+ /help - Show this help message\n\
264
+ /clear - Clear the console chat log\n\
265
+ /about - Display information about COBOLX\n\
266
+ /model - Show current model routing setting\n\
267
+ /model auto - Set routing to Auto (via Router Sub-Agent)\n\
268
+ /model light - Force routing to Lightweight Model (DeepSeek)\n\
269
+ /model heavy - Force routing to Heavy Model (GLM-4-Pro)\n\
270
+ /config - Open the interactive API Key Configuration Screen\n\
271
+ /tokens - Show model routing and token consumption statistics\n\
272
+ /init - Scan the sandbox directory for COBOL files\n\
273
+ /exit - Close the interactive console";
274
+ self.messages.push(Message {
275
+ sender: Sender::Cobolx,
276
+ text: help_message.to_string(),
277
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
278
+ });
279
+ }
280
+ "init" => {
281
+ if let Some(ref path) = self.sandbox_path {
282
+ self.messages.push(Message {
283
+ sender: Sender::Cobolx,
284
+ text: format!(
285
+ "Scanning sandbox directory (recursive): {}",
286
+ path.to_string_lossy()
287
+ ),
288
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
289
+ });
290
+
291
+ match crate::memory::MemoryStore::open_or_create(path).and_then(
292
+ |mut store| {
293
+ crate::cobol::indexer::index_sandbox(path, &mut store)
294
+ .map(|report| (report, store.db_path().to_path_buf()))
295
+ },
296
+ ) {
297
+ Ok((report, db_path)) => {
298
+ self.discovered_files = report.files.clone();
299
+ if self.discovered_files.is_empty() {
300
+ self.messages.push(Message {
301
+ sender: Sender::Cobolx,
302
+ text: "No COBOL files found in the sandbox (supported: .cbl, .cob, .cpy, .coo).".to_string(),
303
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
304
+ });
305
+ } else {
306
+ self.messages.push(Message {
307
+ sender: Sender::Cobolx,
308
+ text: report.to_message(&db_path),
309
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
310
+ });
311
+ }
312
+ }
313
+ Err(e) => {
314
+ self.messages.push(Message {
315
+ sender: Sender::Cobolx,
316
+ text: format!("Error indexing sandbox: {}", e),
317
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
318
+ });
319
+ }
320
+ }
321
+ } else {
322
+ self.messages.push(Message {
323
+ sender: Sender::Cobolx,
324
+ text: "No sandbox directory selected.".to_string(),
325
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
326
+ });
327
+ }
328
+ }
329
+ "model" => {
330
+ if parts.len() > 1 {
331
+ match parts[1].to_lowercase().as_str() {
332
+ "auto" => {
333
+ self.routing_mode = RoutingMode::Auto;
334
+ self.messages.push(Message {
335
+ sender: Sender::Cobolx,
336
+ text: "Routing mode set to Auto.".to_string(),
337
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
338
+ });
339
+ }
340
+ "light" | "lite" => {
341
+ self.routing_mode = RoutingMode::ForceLight;
342
+ self.messages.push(Message {
343
+ sender: Sender::Cobolx,
344
+ text: "Routing mode set to Force Lightweight Model (DeepSeek).".to_string(),
345
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
346
+ });
347
+ }
348
+ "heavy" => {
349
+ self.routing_mode = RoutingMode::ForceHeavy;
350
+ self.messages.push(Message {
351
+ sender: Sender::Cobolx,
352
+ text: "Routing mode set to Force Heavy Model (GLM-4-Pro)."
353
+ .to_string(),
354
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
355
+ });
356
+ }
357
+ _ => {
358
+ self.messages.push(Message {
359
+ sender: Sender::Cobolx,
360
+ text: "Invalid routing mode. Use auto, light, or heavy."
361
+ .to_string(),
362
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
363
+ });
364
+ }
365
+ }
366
+ } else {
367
+ let current = match self.routing_mode {
368
+ RoutingMode::Auto => "Auto",
369
+ RoutingMode::ForceLight => "Force Light (DeepSeek)",
370
+ RoutingMode::ForceHeavy => "Force Heavy (GLM-4-Pro)",
371
+ };
372
+ self.messages.push(Message {
373
+ sender: Sender::Cobolx,
374
+ text: format!("Current routing mode: {}", current),
375
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
376
+ });
377
+ }
378
+ }
379
+ "config" => {
380
+ self.view_mode = ViewMode::Config;
381
+ self.config_active_field = 0;
382
+ }
383
+ "tokens" => {
384
+ let current_routing = match self.routing_mode {
385
+ RoutingMode::Auto => "Auto",
386
+ RoutingMode::ForceLight => "Force Light (DeepSeek)",
387
+ RoutingMode::ForceHeavy => "Force Heavy (GLM-4-Pro)",
388
+ };
389
+ let last_model_str = self.last_model.as_deref().unwrap_or("None");
390
+ let stats = format!(
391
+ "Token Statistics & Routing Config:\n\
392
+ ---------------------------------\n\
393
+ Routing Setting: {}\n\
394
+ Last Active Model: {}\n\
395
+ Last Turn Prompt Tokens: {}\n\
396
+ Last Turn Completion Tokens: {}\n\n\
397
+ Accumulated DeepSeek Prompt Tokens: {}\n\
398
+ Accumulated DeepSeek Completion Tokens: {}\n\n\
399
+ Accumulated GLM-4-Pro Prompt Tokens: {}\n\
400
+ Accumulated GLM-4-Pro Completion Tokens: {}",
401
+ current_routing,
402
+ last_model_str,
403
+ self.last_prompt_tokens,
404
+ self.last_completion_tokens,
405
+ self.deepseek_prompt_tokens,
406
+ self.deepseek_completion_tokens,
407
+ self.glm_prompt_tokens,
408
+ self.glm_completion_tokens
409
+ );
410
+ self.messages.push(Message {
411
+ sender: Sender::Cobolx,
412
+ text: stats,
413
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
414
+ });
415
+ }
416
+ _ => {
417
+ self.messages.push(Message {
418
+ sender: Sender::Cobolx,
419
+ text: format!("Unknown command: /{}", cmd),
420
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
421
+ });
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ self.input_text.clear();
428
+ (should_exit, is_command)
429
+ }
430
+ }
431
+
432
+ fn trigger_chat_task(app: &mut App, tx: &tokio::sync::mpsc::UnboundedSender<TaskUpdate>) -> bool {
433
+ let (should_exit, is_command) = app.submit_message();
434
+ if should_exit {
435
+ return true;
436
+ }
437
+ if is_command {
438
+ return false;
439
+ }
440
+
441
+ // Trigger LLM request
442
+ let router = Arc::clone(&app.router);
443
+ let history = app.messages.clone();
444
+ let routing_mode = app.routing_mode;
445
+ let sandbox_path = app.sandbox_path.clone();
446
+ let tx = tx.clone();
447
+
448
+ // Add a placeholder message for the incoming streaming response
449
+ app.messages.push(Message {
450
+ sender: Sender::Cobolx,
451
+ text: "Thinking...".to_string(),
452
+ timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
453
+ });
454
+
455
+ app.active_agent = Some("Router Sub-Agent".to_string());
456
+
457
+ tokio::spawn(async move {
458
+ // 1. Classify route
459
+ let route = match routing_mode {
460
+ RoutingMode::ForceLight => crate::agent::client::Route::Light,
461
+ RoutingMode::ForceHeavy => crate::agent::client::Route::Heavy,
462
+ RoutingMode::Auto => {
463
+ let query = history.last().map(|m| m.text.as_str()).unwrap_or("");
464
+ router.classify_route(query).await
465
+ }
466
+ };
467
+
468
+ let route_name = match route {
469
+ crate::agent::client::Route::Light => "Lightweight Model (DeepSeek)",
470
+ crate::agent::client::Route::Heavy => "Heavy Model (GLM-4-Pro)",
471
+ crate::agent::client::Route::Database => "Database Sub-Agent",
472
+ crate::agent::client::Route::Filesystem => "Filesystem Sub-Agent",
473
+ };
474
+
475
+ let _ = tx.send(TaskUpdate::Routed(route, route_name));
476
+
477
+ // 2. Execute chat stream
478
+ let (stream_tx, mut stream_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
479
+
480
+ let tx_clone = tx.clone();
481
+ let stream_handle = tokio::spawn(async move {
482
+ while let Some(delta) = stream_rx.recv().await {
483
+ if let Some(status) = delta.strip_prefix("\x01STATUS:") {
484
+ let _ = tx_clone.send(TaskUpdate::Status(status.to_string()));
485
+ } else {
486
+ let _ = tx_clone.send(TaskUpdate::Delta(delta, route_name));
487
+ }
488
+ }
489
+ });
490
+
491
+ let res = router
492
+ .execute_chat_stream(&history, route, sandbox_path.as_deref(), stream_tx)
493
+ .await;
494
+
495
+ let _ = stream_handle.await;
496
+
497
+ match res {
498
+ Ok((usage, final_model)) => {
499
+ let _ = tx.send(TaskUpdate::Finished(Ok(usage), final_model));
500
+ }
501
+ Err(e) => {
502
+ let _ = tx.send(TaskUpdate::Finished(Err(e), route_name));
503
+ }
504
+ }
505
+ });
506
+
507
+ false
508
+ }
509
+
510
+ pub fn run_tui() -> Result<(), io::Error> {
511
+ enable_raw_mode()?;
512
+ let mut stdout = io::stdout();
513
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
514
+ let backend = CrosstermBackend::new(stdout);
515
+ let mut terminal = Terminal::new(backend)?;
516
+
517
+ let mut app = App::new();
518
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<TaskUpdate>();
519
+
520
+ loop {
521
+ // Check for updates
522
+ while let Ok(update) = rx.try_recv() {
523
+ match update {
524
+ TaskUpdate::Routed(ref route, model_used) => {
525
+ app.active_agent = Some(model_used.to_string());
526
+ if matches!(
527
+ route,
528
+ crate::agent::client::Route::Database
529
+ | crate::agent::client::Route::Filesystem
530
+ ) {
531
+ app.agent_status = Some(format!("Using {}", model_used));
532
+ }
533
+ if let Some(msg) = app.messages.iter_mut().last() {
534
+ if msg.text == "Thinking..." {
535
+ msg.text = format!("(Routed: {})\nThinking...", model_used);
536
+ }
537
+ }
538
+ }
539
+ TaskUpdate::Delta(delta, model_used) => {
540
+ app.active_agent = Some(model_used.to_string());
541
+ if let Some(msg) = app.messages.iter_mut().last() {
542
+ if msg.text == "Thinking..."
543
+ || (msg.text.starts_with("(Routed:")
544
+ && msg.text.contains("Thinking..."))
545
+ {
546
+ msg.text = format!("(Using {}) {}", model_used, delta);
547
+ } else {
548
+ msg.text.push_str(&delta);
549
+ }
550
+ }
551
+ }
552
+ TaskUpdate::Status(status) => {
553
+ if status.is_empty() {
554
+ app.agent_status = None;
555
+ } else {
556
+ app.agent_status = Some(status);
557
+ }
558
+ }
559
+ TaskUpdate::Finished(res, model_used) => {
560
+ app.active_agent = None;
561
+ app.agent_status = None;
562
+ match res {
563
+ Ok(Some(usage)) => {
564
+ app.last_model = Some(model_used.to_string());
565
+ app.last_prompt_tokens = usage.prompt_tokens;
566
+ app.last_completion_tokens = usage.completion_tokens;
567
+
568
+ if model_used.contains("DeepSeek") {
569
+ app.deepseek_prompt_tokens += usage.prompt_tokens;
570
+ app.deepseek_completion_tokens += usage.completion_tokens;
571
+ } else if model_used.contains("GLM") {
572
+ app.glm_prompt_tokens += usage.prompt_tokens;
573
+ app.glm_completion_tokens += usage.completion_tokens;
574
+ }
575
+ }
576
+ Ok(None) => {
577
+ app.last_model = Some(model_used.to_string());
578
+ app.last_prompt_tokens = 0;
579
+ app.last_completion_tokens = 0;
580
+ }
581
+ Err(err) => {
582
+ if let Some(msg) = app.messages.iter_mut().last() {
583
+ if msg.text.contains("Thinking...") {
584
+ msg.text = format!("(Using {}) Error: {}", model_used, err);
585
+ } else {
586
+ msg.text.push_str(&format!("\n[Error: {}]", err));
587
+ }
588
+ }
589
+ }
590
+ }
591
+ }
592
+ }
593
+ }
594
+
595
+ if app.agent_status.is_some() {
596
+ app.spinner_tick = app.spinner_tick.wrapping_add(1);
597
+ }
598
+ terminal.draw(|f| draw::draw(f, &mut app))?;
599
+
600
+ // Non-blocking poll for crossterm events
601
+ if event::poll(std::time::Duration::from_millis(50))? {
602
+ if let Event::Key(key) = event::read()? {
603
+ if key.code == KeyCode::Char('c')
604
+ && key.modifiers.contains(event::KeyModifiers::CONTROL)
605
+ {
606
+ break;
607
+ }
608
+
609
+ if app.view_mode == ViewMode::Config {
610
+ match key.code {
611
+ KeyCode::Esc => {
612
+ if app.router.has_credentials() {
613
+ app.view_mode = ViewMode::Chat;
614
+ }
615
+ }
616
+ KeyCode::Tab | KeyCode::Down => {
617
+ app.config_active_field = (app.config_active_field + 1) % 4;
618
+ }
619
+ KeyCode::Up => {
620
+ app.config_active_field = (app.config_active_field + 3) % 4;
621
+ }
622
+ KeyCode::Enter => match app.config_active_field {
623
+ 0 => {
624
+ app.config_active_field = 1;
625
+ }
626
+ 1 => {
627
+ app.config_active_field = 2;
628
+ }
629
+ 2 => {
630
+ let new_data = crate::config::ConfigData {
631
+ deepseek_api_key: app.config_deepseek_input.trim().to_string(),
632
+ glm_api_key: app.config_glm_input.trim().to_string(),
633
+ };
634
+ match crate::config::ConfigManager::save(&new_data) {
635
+ Ok(_) => {
636
+ app.router = Arc::new(AgentRouter::new());
637
+ app.view_mode = ViewMode::SandboxSelect;
638
+ app.messages.push(Message {
639
+ sender: Sender::Cobolx,
640
+ text: "Configuration successfully saved and reloaded!"
641
+ .to_string(),
642
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
643
+ });
644
+ }
645
+ Err(e) => {
646
+ app.messages.push(Message {
647
+ sender: Sender::Cobolx,
648
+ text: format!("Error saving configuration: {}", e),
649
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
650
+ });
651
+ }
652
+ }
653
+ }
654
+ 3 => {
655
+ if app.router.has_credentials() {
656
+ app.view_mode = ViewMode::Chat;
657
+ } else {
658
+ app.messages.push(Message {
659
+ sender: Sender::Cobolx,
660
+ text: "Cannot cancel configuration: No API credentials found. Please set at least one key to save.".to_string(),
661
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
662
+ });
663
+ }
664
+ }
665
+ _ => {}
666
+ },
667
+ KeyCode::Char(c) => {
668
+ if app.config_active_field == 0 {
669
+ app.config_deepseek_input.push(c);
670
+ } else if app.config_active_field == 1 {
671
+ app.config_glm_input.push(c);
672
+ }
673
+ }
674
+ KeyCode::Backspace => {
675
+ if app.config_active_field == 0 {
676
+ app.config_deepseek_input.pop();
677
+ } else if app.config_active_field == 1 {
678
+ app.config_glm_input.pop();
679
+ }
680
+ }
681
+ _ => {}
682
+ }
683
+ } else if app.view_mode == ViewMode::SandboxSelect {
684
+ match key.code {
685
+ KeyCode::Tab | KeyCode::Down | KeyCode::Up => {
686
+ app.sandbox_active_option = 1 - app.sandbox_active_option;
687
+ }
688
+ KeyCode::Enter => {
689
+ let resolved = if app.sandbox_active_option == 0 {
690
+ std::env::current_dir().ok()
691
+ } else {
692
+ std::env::current_dir()
693
+ .ok()
694
+ .and_then(|p| p.parent().map(|parent| parent.to_path_buf()))
695
+ };
696
+ if let Some(path) = resolved {
697
+ app.sandbox_path = Some(path.clone());
698
+ app.view_mode = ViewMode::Chat;
699
+ app.messages.push(Message {
700
+ sender: Sender::Cobolx,
701
+ text: format!("Sandbox directory set to: {}\nType /init to scan COBOL files in the sandbox.", path.to_string_lossy()),
702
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
703
+ });
704
+ } else {
705
+ app.messages.push(Message {
706
+ sender: Sender::Cobolx,
707
+ text: "Failed to resolve sandbox path. Please select current directory.".to_string(),
708
+ timestamp: Local::now().format("%H:%M:%S").to_string(),
709
+ });
710
+ }
711
+ }
712
+ _ => {}
713
+ }
714
+ } else {
715
+ let dropdown_type = app.get_dropdown_type();
716
+ let has_options = dropdown_type != DropdownType::None;
717
+
718
+ match key.code {
719
+ KeyCode::Esc => {
720
+ if app.show_dropdown {
721
+ app.show_dropdown = false;
722
+ } else if app.input_text.is_empty() {
723
+ break;
724
+ } else {
725
+ app.input_text.clear();
726
+ }
727
+ }
728
+ KeyCode::Down | KeyCode::Tab => {
729
+ if app.show_dropdown && has_options {
730
+ let len = match dropdown_type {
731
+ DropdownType::Commands => app.get_filtered_commands().len(),
732
+ DropdownType::Files => app.get_filtered_files().len(),
733
+ _ => 0,
734
+ };
735
+ if len > 0 {
736
+ app.dropdown_index = (app.dropdown_index + 1) % len;
737
+ }
738
+ }
739
+ }
740
+ KeyCode::Up => {
741
+ if app.show_dropdown && has_options {
742
+ let len = match dropdown_type {
743
+ DropdownType::Commands => app.get_filtered_commands().len(),
744
+ DropdownType::Files => app.get_filtered_files().len(),
745
+ _ => 0,
746
+ };
747
+ if len > 0 {
748
+ app.dropdown_index = (app.dropdown_index + len - 1) % len;
749
+ }
750
+ }
751
+ }
752
+ KeyCode::Enter => {
753
+ if app.show_dropdown && has_options {
754
+ match dropdown_type {
755
+ DropdownType::Commands => {
756
+ let filtered = app.get_filtered_commands();
757
+ app.input_text = filtered[app.dropdown_index].clone();
758
+ app.show_dropdown = false;
759
+ if trigger_chat_task(&mut app, &tx) {
760
+ break;
761
+ }
762
+ }
763
+ DropdownType::Files => {
764
+ let filtered = app.get_filtered_files();
765
+ app.insert_selected_file(&filtered[app.dropdown_index]);
766
+ app.show_dropdown = false;
767
+ }
768
+ _ => {}
769
+ }
770
+ } else {
771
+ if trigger_chat_task(&mut app, &tx) {
772
+ break;
773
+ }
774
+ }
775
+ }
776
+ KeyCode::Char(c) => {
777
+ app.input_text.push(c);
778
+ let new_type = app.get_dropdown_type();
779
+ if new_type != DropdownType::None {
780
+ app.show_dropdown = true;
781
+ app.dropdown_index = 0;
782
+ } else {
783
+ app.show_dropdown = false;
784
+ }
785
+ }
786
+ KeyCode::Backspace => {
787
+ app.input_text.pop();
788
+ let new_type = app.get_dropdown_type();
789
+ if new_type != DropdownType::None {
790
+ app.show_dropdown = true;
791
+ app.dropdown_index = 0;
792
+ } else {
793
+ app.show_dropdown = false;
794
+ }
795
+ }
796
+ _ => {}
797
+ }
798
+ }
799
+ }
800
+ }
801
+ }
802
+
803
+ disable_raw_mode()?;
804
+ execute!(
805
+ terminal.backend_mut(),
806
+ LeaveAlternateScreen,
807
+ DisableMouseCapture
808
+ )?;
809
+ terminal.show_cursor()?;
810
+
811
+ Ok(())
812
+ }