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/draw.rs ADDED
@@ -0,0 +1,519 @@
1
+ use crate::ui::tui::{App, Sender};
2
+ use ratatui::{
3
+ Frame,
4
+ layout::{Constraint, Direction, Layout, Rect},
5
+ style::{Color, Modifier, Style},
6
+ text::{Line, Span},
7
+ widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
8
+ };
9
+
10
+ pub fn draw(f: &mut Frame, app: &mut App) {
11
+ let has_status = app.agent_status.is_some();
12
+
13
+ // vertical screen layout
14
+ let chunks = Layout::default()
15
+ .direction(Direction::Vertical)
16
+ .margin(1)
17
+ .constraints(if has_status {
18
+ vec![
19
+ Constraint::Length(8), // Spring Boot-style ASCII banner
20
+ Constraint::Min(3), // Chat Console log
21
+ Constraint::Length(1), // Agent status line
22
+ Constraint::Length(3), // Input prompt
23
+ Constraint::Length(3), // Footer instructions
24
+ ]
25
+ } else {
26
+ vec![
27
+ Constraint::Length(8), // Spring Boot-style ASCII banner
28
+ Constraint::Min(3), // Chat Console log
29
+ Constraint::Length(3), // Input prompt
30
+ Constraint::Length(3), // Footer instructions
31
+ ]
32
+ })
33
+ .split(f.size());
34
+
35
+ let (status_idx, input_idx, footer_idx) = if has_status {
36
+ (Some(2), 3, 4)
37
+ } else {
38
+ (None, 2, 3)
39
+ };
40
+
41
+ // 1. Spring Boot-Style ASCII Banner
42
+ let banner_lines = vec![
43
+ Line::from(Span::styled(
44
+ " ____ ___ ____ ___ _ __ __ ",
45
+ Style::default().fg(Color::Green),
46
+ )),
47
+ Line::from(Span::styled(
48
+ " / ___/ _ \\| __ ) / _ \\| | \\ \\/ / ",
49
+ Style::default().fg(Color::Green),
50
+ )),
51
+ Line::from(Span::styled(
52
+ "| | | | | | _ \\| | | | | \\ / ",
53
+ Style::default().fg(Color::Green),
54
+ )),
55
+ Line::from(Span::styled(
56
+ "| |__| |_| | |_) | |_| | |___ / \\ ",
57
+ Style::default().fg(Color::Green),
58
+ )),
59
+ Line::from(Span::styled(
60
+ " \\____\\___/|____/ \\___/|_____|/_/\\_\\ ",
61
+ Style::default().fg(Color::Green),
62
+ )),
63
+ Line::from(vec![
64
+ Span::styled(
65
+ " :: COBOLX ::",
66
+ Style::default()
67
+ .fg(Color::Green)
68
+ .add_modifier(Modifier::BOLD),
69
+ ),
70
+ Span::styled(
71
+ " (v1.0.0)",
72
+ Style::default().fg(Color::DarkGray),
73
+ ),
74
+ ]),
75
+ ];
76
+
77
+ let header_block = Paragraph::new(banner_lines).block(
78
+ Block::default()
79
+ .borders(Borders::BOTTOM)
80
+ .border_style(Style::default().fg(Color::DarkGray)),
81
+ );
82
+ f.render_widget(header_block, chunks[0]);
83
+
84
+ if app.view_mode == crate::ui::tui::ViewMode::SandboxSelect {
85
+ let current_dir = std::env::current_dir().unwrap_or_default();
86
+ let parent_dir = current_dir
87
+ .parent()
88
+ .map(|p| p.to_path_buf())
89
+ .unwrap_or_else(|| current_dir.clone());
90
+
91
+ let current_border_color = if app.sandbox_active_option == 0 {
92
+ Color::Green
93
+ } else {
94
+ Color::DarkGray
95
+ };
96
+ let parent_border_color = if app.sandbox_active_option == 1 {
97
+ Color::Green
98
+ } else {
99
+ Color::DarkGray
100
+ };
101
+
102
+ let current_style = if app.sandbox_active_option == 0 {
103
+ Style::default().fg(Color::LightGreen)
104
+ } else {
105
+ Style::default().fg(Color::Gray)
106
+ };
107
+ let parent_style = if app.sandbox_active_option == 1 {
108
+ Style::default().fg(Color::LightGreen)
109
+ } else {
110
+ Style::default().fg(Color::Gray)
111
+ };
112
+
113
+ let sandbox_chunks = Layout::default()
114
+ .direction(Direction::Vertical)
115
+ .constraints(
116
+ [
117
+ Constraint::Length(2), // Empty space
118
+ Constraint::Length(4), // Option 1
119
+ Constraint::Length(1), // Spacer
120
+ Constraint::Length(4), // Option 2
121
+ Constraint::Min(1),
122
+ ]
123
+ .as_ref(),
124
+ )
125
+ .split(chunks[1]);
126
+
127
+ let opt1_text = format!(
128
+ " [1] Current Directory (.)\n Path: {}",
129
+ current_dir.to_string_lossy()
130
+ );
131
+ let opt1_widget = Paragraph::new(opt1_text).style(current_style).block(
132
+ Block::default()
133
+ .borders(Borders::ALL)
134
+ .title(" Option A ")
135
+ .border_style(Style::default().fg(current_border_color)),
136
+ );
137
+ f.render_widget(opt1_widget, sandbox_chunks[1]);
138
+
139
+ let opt2_text = format!(
140
+ " [2] Parent Directory (..)\n Path: {}",
141
+ parent_dir.to_string_lossy()
142
+ );
143
+ let opt2_widget = Paragraph::new(opt2_text).style(parent_style).block(
144
+ Block::default()
145
+ .borders(Borders::ALL)
146
+ .title(" Option B ")
147
+ .border_style(Style::default().fg(parent_border_color)),
148
+ );
149
+ f.render_widget(opt2_widget, sandbox_chunks[3]);
150
+
151
+ // Draw instructions
152
+ let sandbox_help = " Tab / Up / Down: Toggle Sandbox Directory | Enter: Confirm Selection | Ctrl+C: Force Exit ";
153
+ let sandbox_help_block = Paragraph::new(sandbox_help).block(
154
+ Block::default()
155
+ .borders(Borders::ALL)
156
+ .title(" Sandbox Selector Instructions ")
157
+ .border_style(Style::default().fg(Color::DarkGray)),
158
+ );
159
+ f.render_widget(sandbox_help_block, chunks[input_idx]);
160
+
161
+ let empty_block = Paragraph::new("").block(Block::default().borders(Borders::NONE));
162
+ f.render_widget(empty_block, chunks[footer_idx]);
163
+
164
+ return;
165
+ }
166
+
167
+ if app.view_mode == crate::ui::tui::ViewMode::Config {
168
+ let ds_border_color = if app.config_active_field == 0 {
169
+ Color::Green
170
+ } else {
171
+ Color::DarkGray
172
+ };
173
+ let glm_border_color = if app.config_active_field == 1 {
174
+ Color::Green
175
+ } else {
176
+ Color::DarkGray
177
+ };
178
+
179
+ let save_style = if app.config_active_field == 2 {
180
+ Style::default()
181
+ .fg(Color::Black)
182
+ .bg(Color::Green)
183
+ .add_modifier(Modifier::BOLD)
184
+ } else {
185
+ Style::default().fg(Color::Green)
186
+ };
187
+ let cancel_style = if app.config_active_field == 3 {
188
+ Style::default()
189
+ .fg(Color::Black)
190
+ .bg(Color::Green)
191
+ .add_modifier(Modifier::BOLD)
192
+ } else {
193
+ Style::default().fg(Color::Gray)
194
+ };
195
+
196
+ let form_chunks = Layout::default()
197
+ .direction(Direction::Vertical)
198
+ .constraints(
199
+ [
200
+ Constraint::Length(1), // empty
201
+ Constraint::Length(3), // DeepSeek Input
202
+ Constraint::Length(1), // empty
203
+ Constraint::Length(3), // GLM Input
204
+ Constraint::Length(2), // empty
205
+ Constraint::Length(3), // Buttons
206
+ Constraint::Min(1),
207
+ ]
208
+ .as_ref(),
209
+ )
210
+ .split(chunks[1]);
211
+
212
+ let mut ds_text = app.config_deepseek_input.clone();
213
+ if app.config_active_field == 0 {
214
+ ds_text.push('█');
215
+ }
216
+ let ds_widget = Paragraph::new(ds_text).block(
217
+ Block::default()
218
+ .borders(Borders::ALL)
219
+ .title(" [1] DeepSeek API Key (deepseek-chat) ")
220
+ .border_style(Style::default().fg(ds_border_color)),
221
+ );
222
+ f.render_widget(ds_widget, form_chunks[1]);
223
+
224
+ let mut glm_text = app.config_glm_input.clone();
225
+ if app.config_active_field == 1 {
226
+ glm_text.push('█');
227
+ }
228
+ let glm_widget = Paragraph::new(glm_text).block(
229
+ Block::default()
230
+ .borders(Borders::ALL)
231
+ .title(" [2] GLM-4-Pro API Key (glm-4-pro) ")
232
+ .border_style(Style::default().fg(glm_border_color)),
233
+ );
234
+ f.render_widget(glm_widget, form_chunks[3]);
235
+
236
+ let button_chunks = Layout::default()
237
+ .direction(Direction::Horizontal)
238
+ .constraints(
239
+ [
240
+ Constraint::Percentage(30),
241
+ Constraint::Percentage(20), // Save
242
+ Constraint::Percentage(20), // Cancel
243
+ Constraint::Percentage(30),
244
+ ]
245
+ .as_ref(),
246
+ )
247
+ .split(form_chunks[5]);
248
+
249
+ let save_p = Paragraph::new(" [ SAVE ] ")
250
+ .style(save_style)
251
+ .block(Block::default().borders(Borders::NONE));
252
+ f.render_widget(save_p, button_chunks[1]);
253
+
254
+ let cancel_p = Paragraph::new(" [ CANCEL ] ")
255
+ .style(cancel_style)
256
+ .block(Block::default().borders(Borders::NONE));
257
+ f.render_widget(cancel_p, button_chunks[2]);
258
+
259
+ let config_help = " Tab / Arrow Keys: Move Focus | Type: Input | Enter: Select/Save | Esc: Return to Chat (if keys configured) ";
260
+ let config_help_block = Paragraph::new(config_help).block(
261
+ Block::default()
262
+ .borders(Borders::ALL)
263
+ .title(" Config Instructions ")
264
+ .border_style(Style::default().fg(Color::DarkGray)),
265
+ );
266
+ f.render_widget(config_help_block, chunks[input_idx]);
267
+
268
+ let empty_block = Paragraph::new("").block(Block::default().borders(Borders::NONE));
269
+ f.render_widget(empty_block, chunks[footer_idx]);
270
+
271
+ return;
272
+ }
273
+
274
+ // 2. Chat Log Panel (COBOLX Console)
275
+ let mut display_lines = Vec::new();
276
+ for msg in &app.messages {
277
+ let (prefix, color) = match msg.sender {
278
+ Sender::User => (" [User] ", Color::Cyan),
279
+ Sender::Cobolx => (" [COBOLX] ", Color::Green),
280
+ };
281
+
282
+ // Time indicator
283
+ let time_span = Span::styled(
284
+ format!(" ({})", msg.timestamp),
285
+ Style::default().fg(Color::DarkGray),
286
+ );
287
+
288
+ // Sender tag
289
+ let sender_span = Span::styled(
290
+ prefix,
291
+ Style::default().fg(color).add_modifier(Modifier::BOLD),
292
+ );
293
+
294
+ // Separator
295
+ let sep_span = Span::styled(" : ", Style::default().fg(Color::Gray));
296
+
297
+ // Message text
298
+ let text_style = match msg.sender {
299
+ Sender::User => Style::default().fg(Color::White),
300
+ Sender::Cobolx => Style::default().fg(Color::LightGreen),
301
+ };
302
+
303
+ let lines: Vec<&str> = msg.text.split('\n').collect();
304
+ for (i, line_str) in lines.iter().enumerate() {
305
+ if i == 0 {
306
+ display_lines.push(Line::from(vec![
307
+ time_span.clone(),
308
+ sender_span.clone(),
309
+ sep_span.clone(),
310
+ Span::styled(*line_str, text_style),
311
+ ]));
312
+ } else {
313
+ display_lines.push(Line::from(vec![Span::styled(
314
+ format!(" {}", line_str),
315
+ text_style,
316
+ )]));
317
+ }
318
+ }
319
+
320
+ display_lines.push(Line::from(""));
321
+ }
322
+
323
+ let log_height = chunks[1].height as usize;
324
+ let available_lines = if log_height > 2 { log_height - 2 } else { 0 };
325
+
326
+ let console_width = if chunks[1].width > 2 {
327
+ chunks[1].width - 2
328
+ } else {
329
+ 1
330
+ } as usize;
331
+ let mut total_wrapped_height = 0;
332
+ for line in &display_lines {
333
+ let content_len: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
334
+ let line_height = if content_len == 0 {
335
+ 1
336
+ } else {
337
+ (content_len + console_width - 1) / console_width
338
+ };
339
+ total_wrapped_height += line_height;
340
+ }
341
+
342
+ let scroll_y = if total_wrapped_height > available_lines {
343
+ (total_wrapped_height - available_lines) as u16
344
+ } else {
345
+ 0
346
+ };
347
+
348
+ let console_title = match &app.active_agent {
349
+ Some(agent) => format!(" COBOLX Console [Active: {}] ", agent),
350
+ None => " COBOLX Console ".to_string(),
351
+ };
352
+ let border_color = if app.active_agent.is_some() {
353
+ Color::Green
354
+ } else {
355
+ Color::DarkGray
356
+ };
357
+
358
+ let console_block = Paragraph::new(display_lines)
359
+ .block(
360
+ Block::default()
361
+ .borders(Borders::ALL)
362
+ .title(console_title)
363
+ .border_style(Style::default().fg(border_color)),
364
+ )
365
+ .wrap(ratatui::widgets::Wrap { trim: false })
366
+ .scroll((scroll_y, 0));
367
+ f.render_widget(console_block, chunks[1]);
368
+
369
+ // 2.5. Agent Status Line (spinner)
370
+ if let Some(ref status_idx_val) = status_idx {
371
+ let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
372
+ let frame = spinner_frames[app.spinner_tick % spinner_frames.len()];
373
+ let status_text = app.agent_status.as_deref().unwrap_or("");
374
+ let status_line = Line::from(vec![
375
+ Span::styled(format!(" {} ", frame), Style::default().fg(Color::Yellow)),
376
+ Span::styled(
377
+ status_text,
378
+ Style::default()
379
+ .fg(Color::Yellow)
380
+ .add_modifier(Modifier::ITALIC),
381
+ ),
382
+ ]);
383
+ let status_widget = Paragraph::new(status_line);
384
+ f.render_widget(status_widget, chunks[*status_idx_val]);
385
+ }
386
+
387
+ // 3. Input Prompt
388
+ let mut input_text = app.input_text.clone();
389
+ input_text.push('█'); // Block terminal cursor
390
+
391
+ let input_block = Paragraph::new(input_text).block(
392
+ Block::default()
393
+ .borders(Borders::ALL)
394
+ .title(" Type message to COBOLX ")
395
+ .border_style(Style::default().fg(Color::Green)),
396
+ );
397
+ f.render_widget(input_block, chunks[input_idx]);
398
+
399
+ // 4. Footer Help
400
+ let help_text = " Type /help for commands | Enter: Send | Esc (when input is empty): Exit TUI | Ctrl+C: Force Exit ";
401
+ let help_block = Paragraph::new(help_text).block(
402
+ Block::default()
403
+ .borders(Borders::ALL)
404
+ .title(" Instructions ")
405
+ .border_style(Style::default().fg(Color::DarkGray)),
406
+ );
407
+ f.render_widget(help_block, chunks[footer_idx]);
408
+
409
+ // 5. Autocomplete Dropdown popup (renders overlay above input prompt)
410
+ let dropdown_type = app.get_dropdown_type();
411
+ if app.show_dropdown && dropdown_type != crate::ui::tui::DropdownType::None {
412
+ let (items, title) = match dropdown_type {
413
+ crate::ui::tui::DropdownType::Commands => {
414
+ let filtered = app.get_filtered_commands();
415
+ let list_items: Vec<ListItem> = filtered
416
+ .iter()
417
+ .enumerate()
418
+ .map(|(idx, cmd)| {
419
+ let style = if idx == app.dropdown_index {
420
+ Style::default().fg(Color::Black).bg(Color::Green)
421
+ } else {
422
+ Style::default().fg(Color::Green)
423
+ };
424
+
425
+ let desc = match cmd.as_str() {
426
+ "/help" => " Show help list",
427
+ "/clear" => " Clear console history",
428
+ "/about" => " About COBOLX",
429
+ "/model" => " Model routing override",
430
+ "/config" => " Open API configuration",
431
+ "/tokens" => " Show token consumption statistics",
432
+ "/init" => " Scan sandbox directory for COBOL",
433
+ "/exit" => " Exit TUI",
434
+ _ => "",
435
+ };
436
+
437
+ ListItem::new(Line::from(vec![
438
+ Span::styled(format!("{:<8}", cmd), style.add_modifier(Modifier::BOLD)),
439
+ Span::styled(desc, Style::default().fg(Color::DarkGray)),
440
+ ]))
441
+ })
442
+ .collect();
443
+ (list_items, " Commands ".to_string())
444
+ }
445
+ crate::ui::tui::DropdownType::Files => {
446
+ let filtered = app.get_filtered_files();
447
+ let max_visible = 10;
448
+ let total = filtered.len();
449
+ let start = if total <= max_visible {
450
+ 0
451
+ } else if app.dropdown_index < max_visible / 2 {
452
+ 0
453
+ } else if app.dropdown_index >= total - max_visible / 2 {
454
+ total - max_visible
455
+ } else {
456
+ app.dropdown_index - max_visible / 2
457
+ };
458
+ let end = (start + max_visible).min(total);
459
+ let list_items: Vec<ListItem> = filtered[start..end]
460
+ .iter()
461
+ .enumerate()
462
+ .map(|(i, file)| {
463
+ let actual_idx = start + i;
464
+ let style = if actual_idx == app.dropdown_index {
465
+ Style::default().fg(Color::Black).bg(Color::Green)
466
+ } else {
467
+ Style::default().fg(Color::Green)
468
+ };
469
+
470
+ let indicator = if total > max_visible {
471
+ if actual_idx == start && start > 0 {
472
+ " ▲"
473
+ } else if actual_idx == end - 1 && end < total {
474
+ " ▼"
475
+ } else {
476
+ ""
477
+ }
478
+ } else {
479
+ ""
480
+ };
481
+
482
+ ListItem::new(Line::from(vec![
483
+ Span::styled(file.clone(), style),
484
+ Span::styled(indicator, Style::default().fg(Color::DarkGray)),
485
+ ]))
486
+ })
487
+ .collect();
488
+ let title = if total > max_visible {
489
+ format!(" Files ({}/{}) ", app.dropdown_index + 1, total)
490
+ } else {
491
+ " Files ".to_string()
492
+ };
493
+ (list_items, title)
494
+ }
495
+ _ => (Vec::new(), String::new()),
496
+ };
497
+
498
+ if !items.is_empty() {
499
+ let popup_height = (items.len() + 2) as u16;
500
+ let popup_width = 45;
501
+ let popup_rect = Rect {
502
+ x: chunks[input_idx].x + 2,
503
+ y: chunks[input_idx].y.saturating_sub(popup_height),
504
+ width: popup_width.min(chunks[input_idx].width - 4),
505
+ height: popup_height,
506
+ };
507
+
508
+ f.render_widget(Clear, popup_rect);
509
+
510
+ let list = List::new(items).block(
511
+ Block::default()
512
+ .borders(Borders::ALL)
513
+ .title(title)
514
+ .border_style(Style::default().fg(Color::Green)),
515
+ );
516
+ f.render_widget(list, popup_rect);
517
+ }
518
+ }
519
+ }