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) = terminal::size().unwrap_or((80, 24));
19    let fh = app.footer_height; // always 4
20
21    stdout.queue(cursor::Hide)?;
22
23    // ── Line 1 (top of footer): Status ──────────────────────────────
24    let line1_y = term_height.saturating_sub(fh);
25    stdout.queue(cursor::MoveTo(0, line1_y))?;
26    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
27
28    let spinner_chars = vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
29    let spinner = if app.current_task.is_some() || app.awaiting_approval {
30        spinner_chars[app.spinner_frame % spinner_chars.len()]
31            .to_string()
32            .yellow()
33            .to_string()
34    } else {
35        "✨".to_string()
36    };
37
38    let status = if app.awaiting_approval {
39        if app.is_path_traversal_warning {
40            " ⚠️ AWAITING APPROVAL (y/n) ".red().to_string()
41        } else {
42            " ⚠️ AWAITING APPROVAL (y/n/a) ".red().to_string()
43        }
44    } else if let Some(task) = &app.current_task {
45        let elapsed = app
46            .job_start_time
47            .map(|s| format!(" ({:.1}s)", s.elapsed().as_secs_f32()))
48            .unwrap_or_default();
49        format!(" {}...{} ", task, elapsed).blue().to_string()
50    } else {
51        format!(" {} ", app.model).magenta().to_string()
52    };
53
54    let line1 = format!("v{} {}{}", VERSION, spinner, status);
55    stdout.queue(style::Print(line1))?;
56    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
57    stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
58
59    // ── Line 2: Folder + Token info ─────────────────────────────────
60    stdout.queue(cursor::MoveTo(0, term_height.saturating_sub(fh - 1)))?;
61    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
62
63    let total_tokens = app.total_tokens();
64    let token_str = if total_tokens > 0 {
65        format!(
66            " | 📊 {} prompt · {} comp · {} total",
67            app.token_usage.prompt_tokens, app.token_usage.completion_tokens, total_tokens
68        )
69    } else {
70        String::new()
71    };
72
73    let cwd_visible = format!("📂 {} ", app.cwd);
74    let token_visible_len = strip_ansi(&token_str).chars().count();
75    let cwd_visible_len = cwd_visible.chars().count();
76    let max_cwd_len = (term_width as usize).saturating_sub(token_visible_len + 2);
77
78    let cwd_display = if cwd_visible_len > max_cwd_len && max_cwd_len > 3 {
79        format!(
80            "📂 ...{} ",
81            &app.cwd[app.cwd.len().saturating_sub(max_cwd_len - 6)..]
82        )
83    } else {
84        cwd_visible
85    };
86
87    let line2 = format!("{}{}", cwd_display.blue(), token_str.dim());
88    stdout.queue(style::Print(line2))?;
89    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
90    stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
91
92    // ── Line 3: Input prompt ────────────────────────────────────────
93    let line3_y = term_height.saturating_sub(2);
94    stdout.queue(cursor::MoveTo(0, line3_y))?;
95    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
96
97    let prompt = "> ";
98    let avail = (term_width as usize).saturating_sub(3); // "> " + 1 char margin
99    let input_display = if app.input.chars().count() <= avail || avail == 0 {
100        app.input.clone()
101    } else {
102        // Show tail portion near cursor
103        let skip = app.input.chars().count().saturating_sub(avail);
104        app.input.chars().skip(skip).collect()
105    };
106    let line3 = format!("{}{}", prompt.cyan(), input_display);
107    stdout.queue(style::Print(line3))?;
108    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
109    stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
110
111    // Cursor X: prompt width + cursor char offset (relative to visible portion)
112    let visible_input_chars = app.input.chars().count();
113    let visible_offset = if visible_input_chars > avail && avail > 0 {
114        visible_input_chars.saturating_sub(avail)
115    } else {
116        0
117    };
118    let cursor_byte_pos = app.cursor_pos.min(app.input.len());
119    let safe_cursor_pos = if app.input.is_char_boundary(cursor_byte_pos) {
120        cursor_byte_pos
121    } else {
122        let mut p = cursor_byte_pos;
123        while p > 0 && !app.input.is_char_boundary(p) {
124            p -= 1;
125        }
126        p
127    };
128    let cursor_char = app.input[..safe_cursor_pos].chars().count();
129    let cursor_x = 2 + ((cursor_char.saturating_sub(visible_offset)) as u16);
130
131    // ── Line 4 (bottom): Queue entries horizontal ───────────────────
132    let line4_y = term_height.saturating_sub(1);
133    stdout.queue(cursor::MoveTo(0, line4_y))?;
134    stdout.queue(style::SetBackgroundColor(style::Color::Black))?;
135
136    if !app.queued_commands.is_empty() {
137        // Build queue display: "q1: cmd1  q2: cmd2  ..."
138        let mut parts: Vec<String> = Vec::new();
139        let separator = "  ";
140
141        // Estimate max entries that fit on one line
142        let max_entries = (term_width as usize / 15).max(1);
143
144        for i in 0..app.queued_commands.len().min(max_entries) {
145            if i > 0 {
146                parts.push(separator.to_string());
147            }
148            let cmd = &app.queued_commands[i];
149            let prefix = if i == 0 && app.current_task.is_some() {
150                format!("▶ q{}:", i + 1)
151            } else if i == 0 {
152                format!("✓ q{}:", i + 1)
153            } else {
154                format!("q{}:", i + 1)
155            };
156            let prefix_len = prefix.chars().count();
157            let cmd_max = 30usize.saturating_sub(prefix_len);
158            let truncated_cmd = truncate_str(cmd, cmd_max);
159
160            // Styled entry
161            let entry: String = if i == 0 && app.current_task.is_some() {
162                format!("{}{}", prefix.green(), truncated_cmd)
163            } else if i == 0 {
164                format!("{}{}", prefix.dim(), truncated_cmd.dim())
165            } else {
166                format!("{}{}", prefix.yellow(), truncated_cmd.dim())
167            };
168            parts.push(entry);
169        }
170
171        let queue_line = parts.join("");
172        // Truncate to terminal width (account for ANSI codes properly)
173        let truncated = truncate_ansi_str(&queue_line, term_width as usize);
174        stdout.queue(style::Print(truncated))?;
175    }
176
177    stdout.queue(terminal::Clear(terminal::ClearType::UntilNewLine))?;
178
179    // Reset styles
180    stdout.queue(style::SetBackgroundColor(style::Color::Reset))?;
181    stdout.queue(style::ResetColor)?;
182
183    // Position cursor on the input line
184    stdout.queue(cursor::MoveTo(cursor_x, line3_y))?;
185    stdout.queue(cursor::Show)?;
186    stdout.flush()?;
187
188    Ok(())
189}
190
191pub fn write_to_output(stdout: &mut io::Stdout, app: &mut App, text: String) -> io::Result<()> {
192    let (term_width, term_height) = terminal::size().unwrap_or((80, 24));
193    let log_height = term_height.saturating_sub(app.footer_height);
194    let max_cols = term_width;
195
196    // Move to the current log position
197    stdout.queue(cursor::MoveTo(app.log_x, app.log_y))?;
198
199    let chars: Vec<char> = text.chars().collect();
200    let mut i = 0;
201    let mut buffer = String::new();
202
203    while i < chars.len() {
204        if chars[i] == '\x1b' && i + 1 < chars.len() && chars[i + 1] == '[' {
205            // Flush text buffer before printing escape sequence
206            if !buffer.is_empty() {
207                stdout.queue(style::Print(&buffer))?;
208                buffer.clear();
209            }
210
211            let mut seq = String::new();
212            seq.push('\x1b');
213            seq.push('[');
214            i += 2;
215            while i < chars.len() {
216                let c = chars[i];
217                seq.push(c);
218                i += 1;
219                if (c as u32) >= 64 && (c as u32) <= 126 {
220                    break;
221                }
222            }
223            stdout.queue(style::Print(seq))?;
224        } else if chars[i] == '\n' {
225            // Flush text buffer before newline
226            if !buffer.is_empty() {
227                stdout.queue(style::Print(&buffer))?;
228                buffer.clear();
229            }
230
231            stdout.queue(style::Print("\r\n"))?;
232            app.log_x = 0;
233            if app.log_y < log_height.saturating_sub(1) {
234                app.log_y += 1;
235            }
236            i += 1;
237        } else if chars[i] == '\r' {
238            // Flush text buffer before carriage return
239            if !buffer.is_empty() {
240                stdout.queue(style::Print(&buffer))?;
241                buffer.clear();
242            }
243            app.log_x = 0;
244            i += 1;
245        } else {
246            if app.log_x >= max_cols {
247                // Flush text buffer before wrapping
248                if !buffer.is_empty() {
249                    stdout.queue(style::Print(&buffer))?;
250                    buffer.clear();
251                }
252                stdout.queue(style::Print("\r\n"))?;
253                app.log_x = 0;
254                if app.log_y < log_height.saturating_sub(1) {
255                    app.log_y += 1;
256                }
257            }
258            buffer.push(chars[i]);
259            app.log_x += 1;
260            i += 1;
261        }
262    }
263
264    if !buffer.is_empty() {
265        stdout.queue(style::Print(buffer))?;
266    }
267
268    stdout.flush()?;
269    Ok(())
270}