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/.devcontainer/devcontainer.json +26 -0
- package/.dockerignore +4 -0
- package/.github/workflows/ci.yml +157 -0
- package/Cargo.lock +2245 -0
- package/Cargo.toml +39 -0
- package/bin/check-update.js +44 -0
- package/bin/cobolx.js +81 -0
- package/docker-compose.yml +33 -0
- package/dockerfile +18 -0
- package/dockerfile.test +39 -0
- package/package.json +27 -0
- package/scripts/install.js +145 -0
- package/src/agent/client.rs +1345 -0
- package/src/agent.rs +1 -0
- package/src/cobol/copybook.rs +71 -0
- package/src/cobol/data_parser.rs +290 -0
- package/src/cobol/indexer.rs +256 -0
- package/src/cobol/layout.rs +278 -0
- package/src/cobol/lexer.rs +135 -0
- package/src/cobol/model.rs +196 -0
- package/src/cobol/scanner.rs +72 -0
- package/src/cobol/source_parser.rs +91 -0
- package/src/cobol.rs +8 -0
- package/src/config/config.rs +64 -0
- package/src/config.rs +3 -0
- package/src/lib.rs +6 -0
- package/src/main.rs +20 -0
- package/src/memory/files.rs +155 -0
- package/src/memory/store.rs +406 -0
- package/src/memory.rs +5 -0
- package/src/ui/draw.rs +519 -0
- package/src/ui/tui.rs +812 -0
- package/src/ui.rs +2 -0
- package/tests/indexer_tests.rs +192 -0
- package/tests/memory_store_tests.rs +21 -0
- package/tests/project_files_tests.rs +72 -0
- package/tests/sandbox_tests.rs +178 -0
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
|
+
}
|