Skip to main content

deepseek_rust_cli/tui/
render.rs

1use 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; // always 4
23
24    stdout.queue(cursor::Hide)?;
25
26    // ── Line 1 (top of footer): Status ──────────────────────────────
27    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    // ── Line 2: Folder + Token info ─────────────────────────────────
63    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    // ── Line 3: Input prompt ────────────────────────────────────────
96    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); // "> " + 1 char margin
102    let input_display = if app.input.chars().count() <= avail || avail == 0 {
103        app.input.clone()
104    } else {
105        // Show tail portion near cursor
106        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    // Cursor X: prompt width + cursor char offset (relative to visible portion)
115    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    // ── Line 4 (bottom): Queue entries horizontal ───────────────────
135    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        // Build queue display: "q1: cmd1  q2: cmd2  ..."
141        let mut parts: Vec<String> = Vec::new();
142        let separator = "  ";
143
144        // Estimate max entries that fit on one line
145        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            // Styled entry
164            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        // Truncate to terminal width (account for ANSI codes properly)
176        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    // Reset styles
183    stdout.queue(style::SetBackgroundColor(style::Color::Reset))?;
184    stdout.queue(style::ResetColor)?;
185
186    // Position cursor on the input line
187    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    // Move to the current log position
215    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            // Flush text buffer before printing escape sequence
224            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            // Flush text buffer before newline
244            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            // Flush text buffer before carriage return
257            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                // Flush text buffer before wrapping
266                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}