deepseek_rust_cli/tui/
render.rs1use std::io::{self, Write};
2
3use crossterm::{
4 cursor,
5 style::{self, Stylize},
6 terminal, QueueableCommand,
7};
8
9use crate::{
10 tui::{
11 app::App,
12 utils::{strip_ansi, truncate_ansi_str, truncate_str},
13 },
14 version::VERSION,
15};
16
17pub fn render_footer(stdout: &mut io::Stdout, app: &App) -> io::Result<()> {
18 let (term_width, term_height) = {
19 let size = app.terminal_size.read().unwrap();
20 (size.width, size.height)
21 };
22 let fh = app.footer_height; stdout.queue(cursor::Hide)?;
25
26 let line1_y = term_height.saturating_sub(fh);
28 stdout.queue(cursor::MoveTo(0, line1_y))?;
29 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
30
31 let spinner_chars = vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
32 let spinner = if app.current_task.is_some() || app.awaiting_approval {
33 spinner_chars[app.spinner_frame % spinner_chars.len()]
34 .to_string()
35 .yellow()
36 .to_string()
37 } else {
38 "✨".to_string()
39 };
40
41 let status = if app.awaiting_approval {
42 if app.is_path_traversal_warning {
43 " ⚠️ AWAITING APPROVAL (y/n) ".red().to_string()
44 } else {
45 " ⚠️ AWAITING APPROVAL (y/n/a) ".red().to_string()
46 }
47 } else if let Some(task) = &app.current_task {
48 let elapsed = app
49 .job_start_time
50 .map(|s| format!(" ({:.1}s)", s.elapsed().as_secs_f32()))
51 .unwrap_or_default();
52 format!(" {}...{} ", task, elapsed).blue().to_string()
53 } else {
54 format!(" {} ", app.model).magenta().to_string()
55 };
56
57 let line1 = format!("v{} {}{}", VERSION, spinner, status);
58 stdout.queue(style::Print(line1))?;
59 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
60 stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
61
62 stdout.queue(cursor::MoveTo(0, term_height.saturating_sub(fh - 1)))?;
64 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
65
66 let total_tokens = app.total_tokens();
67 let token_str = if total_tokens > 0 {
68 format!(
69 " | 📊 {} prompt · {} comp · {} total",
70 app.token_usage.prompt_tokens, app.token_usage.completion_tokens, total_tokens
71 )
72 } else {
73 String::new()
74 };
75
76 let cwd_visible = format!("📂 {} ", app.cwd);
77 let token_visible_len = strip_ansi(&token_str).chars().count();
78 let cwd_visible_len = cwd_visible.chars().count();
79 let max_cwd_len = (term_width as usize).saturating_sub(token_visible_len + 2);
80
81 let cwd_display = if cwd_visible_len > max_cwd_len && max_cwd_len > 3 {
82 format!(
83 "📂 ...{} ",
84 &app.cwd[app.cwd.len().saturating_sub(max_cwd_len - 6)..]
85 )
86 } else {
87 cwd_visible
88 };
89
90 let line2 = format!("{}{}", cwd_display.blue(), token_str.dim());
91 stdout.queue(style::Print(line2))?;
92 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
93 stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
94
95 let line3_y = term_height.saturating_sub(2);
97 stdout.queue(cursor::MoveTo(0, line3_y))?;
98 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
99
100 let prompt = "> ";
101 let avail = (term_width as usize).saturating_sub(3); let input_display = if app.input.chars().count() <= avail || avail == 0 {
103 app.input.clone()
104 } else {
105 let skip = app.input.chars().count().saturating_sub(avail);
107 app.input.chars().skip(skip).collect()
108 };
109 let line3 = format!("{}{}", prompt.cyan(), input_display);
110 stdout.queue(style::Print(line3))?;
111 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
112 stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
113
114 let visible_input_chars = app.input.chars().count();
116 let visible_offset = if visible_input_chars > avail && avail > 0 {
117 visible_input_chars.saturating_sub(avail)
118 } else {
119 0
120 };
121 let cursor_byte_pos = app.cursor_pos.min(app.input.len());
122 let safe_cursor_pos = if app.input.is_char_boundary(cursor_byte_pos) {
123 cursor_byte_pos
124 } else {
125 let mut p = cursor_byte_pos;
126 while p > 0 && !app.input.is_char_boundary(p) {
127 p -= 1;
128 }
129 p
130 };
131 let cursor_char = app.input[..safe_cursor_pos].chars().count();
132 let cursor_x = 2 + ((cursor_char.saturating_sub(visible_offset)) as u16);
133
134 let line4_y = term_height.saturating_sub(1);
136 stdout.queue(cursor::MoveTo(0, line4_y))?;
137 stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
138
139 if !app.queued_commands.is_empty() {
140 let mut parts: Vec<String> = Vec::new();
142 let separator = " ";
143
144 let max_entries = (term_width as usize / 15).max(1);
146
147 for i in 0..app.queued_commands.len().min(max_entries) {
148 if i > 0 {
149 parts.push(separator.to_string());
150 }
151 let cmd = &app.queued_commands[i];
152 let prefix = if i == 0 && app.current_task.is_some() {
153 format!("▶ q{}:", i + 1)
154 } else if i == 0 {
155 format!("✓ q{}:", i + 1)
156 } else {
157 format!("q{}:", i + 1)
158 };
159 let prefix_len = prefix.chars().count();
160 let cmd_max = 30usize.saturating_sub(prefix_len);
161 let truncated_cmd = truncate_str(cmd, cmd_max);
162
163 let entry: String = if i == 0 && app.current_task.is_some() {
165 format!("{}{}", prefix.green(), truncated_cmd)
166 } else if i == 0 {
167 format!("{}{}", prefix.dim(), truncated_cmd.dim())
168 } else {
169 format!("{}{}", prefix.yellow(), truncated_cmd.dim())
170 };
171 parts.push(entry);
172 }
173
174 let queue_line = parts.join("");
175 let truncated = truncate_ansi_str(&queue_line, term_width as usize);
177 stdout.queue(style::Print(truncated))?;
178 }
179
180 stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
181
182 stdout.queue(style::SetBackgroundColor(style::Color::Reset))?;
184 stdout.queue(style::ResetColor)?;
185
186 stdout.queue(cursor::MoveTo(cursor_x, line3_y))?;
188 stdout.queue(cursor::Show)?;
189 stdout.flush()?;
190
191 Ok(())
192}
193
194pub fn write_to_output(stdout: &mut io::Stdout, app: &mut App, text: String) -> io::Result<()> {
195 write_to_output_inner(stdout, app, &text, true)
196}
197
198pub fn write_to_output_inner(
199 stdout: &mut io::Stdout,
200 app: &mut App,
201 text: &str,
202 record: bool,
203) -> io::Result<()> {
204 if record {
205 app.output_buffer.push_str(text);
206 }
207 let (term_width, term_height) = {
208 let size = app.terminal_size.read().unwrap();
209 (size.width, size.height)
210 };
211 let log_height = term_height.saturating_sub(app.footer_height);
212 let max_cols = term_width;
213
214 stdout.queue(cursor::MoveTo(app.log_x, app.log_y))?;
216
217 let chars: Vec<char> = text.chars().collect();
218 let mut i = 0;
219 let mut buffer = String::new();
220
221 while i < chars.len() {
222 if chars[i] == '\x1b' && i + 1 < chars.len() && chars[i + 1] == '[' {
223 if !buffer.is_empty() {
225 stdout.queue(style::Print(&buffer))?;
226 buffer.clear();
227 }
228
229 let mut seq = String::new();
230 seq.push('\x1b');
231 seq.push('[');
232 i += 2;
233 while i < chars.len() {
234 let c = chars[i];
235 seq.push(c);
236 i += 1;
237 if (c as u32) >= 64 && (c as u32) <= 126 {
238 break;
239 }
240 }
241 stdout.queue(style::Print(seq))?;
242 } else if chars[i] == '\n' {
243 if !buffer.is_empty() {
245 stdout.queue(style::Print(&buffer))?;
246 buffer.clear();
247 }
248
249 stdout.queue(style::Print("\r\n"))?;
250 app.log_x = 0;
251 if app.log_y < log_height.saturating_sub(1) {
252 app.log_y += 1;
253 }
254 i += 1;
255 } else if chars[i] == '\r' {
256 if !buffer.is_empty() {
258 stdout.queue(style::Print(&buffer))?;
259 buffer.clear();
260 }
261 app.log_x = 0;
262 i += 1;
263 } else {
264 if app.log_x >= max_cols {
265 if !buffer.is_empty() {
267 stdout.queue(style::Print(&buffer))?;
268 buffer.clear();
269 }
270 stdout.queue(style::Print("\r\n"))?;
271 app.log_x = 0;
272 if app.log_y < log_height.saturating_sub(1) {
273 app.log_y += 1;
274 }
275 }
276 buffer.push(chars[i]);
277 app.log_x += 1;
278 i += 1;
279 }
280 }
281
282 if !buffer.is_empty() {
283 stdout.queue(style::Print(buffer))?;
284 }
285
286 stdout.flush()?;
287 Ok(())
288}