Skip to main content

recursive/tui/ui/
chat.rs

1//! Chat screen renderer (block-aware).
2//!
3//! Goal-144 redraws the messages panel using
4//! [`crate::tui::ui::transcript::render_blocks`] (one block per logical
5//! transcript entry, separated by blank lines) and replaces the old
6//! single-line status bar with the rich
7//! [`crate::tui::ui::status::render`] formatter.
8//!
9//! Goal-145 swaps the single-line input footer for the multi-mode
10//! [`crate::tui::ui::input`] renderer (input box + dynamic height + footer
11//! hint) and lets the terminal native cursor land on the actual edit
12//! position.
13//!
14//! Goal-167 adds a compact task-list panel between the messages area and
15//! the status bar when `current_todos` is non-empty.
16//!
17//! While a turn is running the spinner from
18//! [`crate::tui::ui::spinner::format_line`] is appended after the last
19//! block.
20
21use ratatui::prelude::*;
22use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
23
24use crate::tools::todo::TodoStatus;
25use crate::tui::app::App;
26use crate::tui::ui::{command_menu, input, modal, spinner, status, transcript};
27
28/// Height of the todo panel (border + one row per item, capped at 6 items).
29fn todo_panel_height(app: &App) -> u16 {
30    if app.current_todos.is_empty() {
31        0
32    } else {
33        // 2 for the border + 1 per item (capped so it doesn't take over)
34        (app.current_todos.len().min(6) as u16) + 2
35    }
36}
37
38pub fn render(frame: &mut Frame, app: &App) {
39    let input_total = input::total_height(app);
40    let todo_height = todo_panel_height(app);
41    // Fix-E: show a 1-row approval banner when a plan is awaiting the
42    // user's decision. The banner replaces the floating modal and keeps
43    // the full transcript visible.
44    // Goal-202: also show 1-row banner when plan-mode entry request is pending.
45    let plan_banner_height: u16 = if app.plan_awaiting_approval || app.plan_mode_request_pending {
46        1
47    } else {
48        0
49    };
50    let chunks = Layout::default()
51        .direction(Direction::Vertical)
52        .constraints([
53            Constraint::Min(3),                     // messages
54            Constraint::Length(todo_height),        // Goal-167: task list (0 when empty)
55            Constraint::Length(1),                  // status bar
56            Constraint::Length(plan_banner_height), // Fix-E: plan approval banner
57            Constraint::Length(input_total),        // input + footer hint
58        ])
59        .split(frame.area());
60
61    // Messages panel: only render in-flight blocks (index >= last_printed_idx).
62    // Completed blocks have already been pushed to the terminal's native
63    // scrollback buffer via `terminal.insert_before()` in the main loop.
64    let inflight = &app.blocks[app.last_printed_idx..];
65    let mut lines = transcript::render_blocks(inflight, &app.usage, app.theme);
66    if app.turn.running {
67        let elapsed = app
68            .turn
69            .started_at
70            .map(|t| t.elapsed().as_secs_f64())
71            .unwrap_or(0.0);
72        lines.push(Line::raw(""));
73        lines.push(Line::from(vec![Span::styled(
74            spinner::format_line(app.spinner_frame, app.turn.spinner_verb, elapsed),
75            Style::default().fg(Color::Yellow),
76        )]));
77    }
78
79    let messages_area = chunks[0];
80    let todo_area = chunks[1];
81    // The messages panel no longer wraps in a bordered `Block`, so the
82    // area is the chunk itself — no border rows / columns to subtract.
83    let inner_width = messages_area.width;
84    let visible_rows = messages_area.height;
85
86    // Compute the *visual* (post-wrap) row count for proper scroll
87    // capping. Counting `lines.len()` (logical rows) under-counts when
88    // long messages wrap, which silently caps `scroll_offset` and
89    // prevents scrolling all the way to the top of long transcripts.
90    //
91    // ratatui 0.29's `Paragraph::line_count` is unstable, so we
92    // approximate with `Line::width()` (public, unicode-aware) divided
93    // by the inner width and rounded up. Empty lines still count as 1.
94    let total_rows: u16 = if inner_width == 0 {
95        lines.len() as u16
96    } else {
97        let w = inner_width as usize;
98        let sum: usize = lines
99            .iter()
100            .map(|l| {
101                let lw = l.width();
102                if lw == 0 {
103                    1
104                } else {
105                    lw.div_ceil(w)
106                }
107            })
108            .sum();
109        sum.try_into().unwrap_or(u16::MAX)
110    };
111    let max_scroll = total_rows.saturating_sub(visible_rows);
112    let effective_scroll = max_scroll.saturating_sub(app.scroll_offset.min(max_scroll));
113
114    let messages_widget = Paragraph::new(lines)
115        .wrap(Wrap { trim: false })
116        .scroll((effective_scroll, 0));
117    frame.render_widget(messages_widget, messages_area);
118
119    // Goal-167: task-list panel (only rendered when non-empty).
120    if !app.current_todos.is_empty() {
121        render_todo_panel(frame, todo_area, app);
122    }
123
124    // Status bar.
125    status::render(frame, chunks[2], app);
126
127    // Fix-E: plan approval banner (1 row, visible only when plan_awaiting_approval).
128    // Goal-202: also shown when plan_mode_request_pending.
129    if app.plan_awaiting_approval {
130        render_plan_approval_banner(frame, chunks[3], app);
131    } else if app.plan_mode_request_pending {
132        render_plan_mode_request_banner(frame, chunks[3]);
133    }
134
135    // Input panel + footer hint (chunks[4] when banner present, [3] otherwise).
136    // The layout always reserves the slot; when plan_banner_height=0 the
137    // area has zero height and input renders normally at [4].
138    input::render(frame, chunks[4], app);
139
140    // Goal-146: floating slash-command popup, drawn after the input
141    // box so it overlays the messages panel.
142    command_menu::render(frame, chunks[4], app);
143
144    // Goal-158: @file completion popup.
145    command_menu::render_atfile(frame, chunks[4], app);
146
147    // Goal-160: Ctrl+R history-search popup.
148    command_menu::render_history_search(frame, chunks[4], app);
149
150    // Goal-161: permission-request modal (top layer — covers everything).
151    command_menu::render_permission_modal(frame, app);
152
153    // Goal-146: modals are last so they cover everything else.
154    if !app.modals.is_empty() {
155        modal::render(frame, app);
156    }
157}
158
159/// Render the compact task-list panel.
160///
161/// Shows up to 6 items with ✓/◉/○/✗ status icons. Items beyond the first
162/// 6 are silently truncated (the agent should keep lists short).
163fn render_todo_panel(frame: &mut Frame, area: Rect, app: &App) {
164    let completed = app
165        .current_todos
166        .iter()
167        .filter(|t| t.status == TodoStatus::Completed)
168        .count();
169    let total = app.current_todos.len();
170    let title = format!(" Tasks ({completed}/{total} done) ");
171
172    let items: Vec<Line> = app
173        .current_todos
174        .iter()
175        .take(6)
176        .map(|item| {
177            let (icon, style) = match item.status {
178                TodoStatus::Completed => ("✓", Style::default().fg(Color::Green)),
179                TodoStatus::InProgress => ("◉", Style::default().fg(Color::Yellow)),
180                TodoStatus::Pending => ("○", Style::default().fg(Color::DarkGray)),
181                TodoStatus::Cancelled => ("✗", Style::default().fg(Color::DarkGray)),
182            };
183            let label = item
184                .active_form
185                .as_deref()
186                .filter(|_| item.status == TodoStatus::InProgress)
187                .unwrap_or(&item.content);
188            Line::from(vec![
189                Span::styled(format!(" {icon} "), style),
190                Span::styled(label.to_string(), style),
191            ])
192        })
193        .collect();
194
195    let widget = Paragraph::new(items)
196        .block(Block::default().borders(Borders::ALL).title(title))
197        .wrap(Wrap { trim: true });
198    frame.render_widget(widget, area);
199}
200
201/// Fix-E: render a 1-row plan approval banner between the status bar
202/// and the input box. Visible only while `plan_awaiting_approval` is set.
203///
204/// ```text
205/// ⚡ Plan awaiting approval — [y] Approve  [n] Reject  [e] Edit
206/// ```
207fn render_plan_approval_banner(frame: &mut Frame, area: Rect, _app: &App) {
208    use ratatui::style::Modifier;
209    let line = Line::from(vec![
210        Span::styled(
211            " ⚡ Plan awaiting approval — ",
212            Style::default()
213                .fg(Color::Black)
214                .bg(Color::Yellow)
215                .add_modifier(Modifier::BOLD),
216        ),
217        Span::styled(
218            "[y/Enter]",
219            Style::default()
220                .fg(Color::Black)
221                .bg(Color::Green)
222                .add_modifier(Modifier::BOLD),
223        ),
224        Span::styled(
225            " Approve  ",
226            Style::default().fg(Color::Black).bg(Color::Yellow),
227        ),
228        Span::styled(
229            "[n/Esc]",
230            Style::default()
231                .fg(Color::White)
232                .bg(Color::Red)
233                .add_modifier(Modifier::BOLD),
234        ),
235        Span::styled(
236            " Reject  ",
237            Style::default().fg(Color::Black).bg(Color::Yellow),
238        ),
239        Span::styled(
240            "[e]",
241            Style::default()
242                .fg(Color::Black)
243                .bg(Color::Cyan)
244                .add_modifier(Modifier::BOLD),
245        ),
246        Span::styled(
247            " Edit ",
248            Style::default().fg(Color::Black).bg(Color::Yellow),
249        ),
250    ]);
251    let widget = Paragraph::new(line);
252    frame.render_widget(widget, area);
253}
254
255/// Goal-202: render a 1-row plan-mode request banner between the status bar
256/// and the input box. Visible while `plan_mode_request_pending` is set.
257///
258/// ```text
259///  ⓘ Plan mode request — [y/Enter] Allow   [n/Esc] Skip
260/// ```
261fn render_plan_mode_request_banner(frame: &mut Frame, area: Rect) {
262    use ratatui::style::Modifier;
263    let line = Line::from(vec![
264        Span::styled(
265            " ⓘ Plan mode request — ",
266            Style::default()
267                .fg(Color::Black)
268                .bg(Color::Blue)
269                .add_modifier(Modifier::BOLD),
270        ),
271        Span::styled(
272            "[y/Enter]",
273            Style::default()
274                .fg(Color::Black)
275                .bg(Color::Green)
276                .add_modifier(Modifier::BOLD),
277        ),
278        Span::styled(
279            " Allow  ",
280            Style::default().fg(Color::Black).bg(Color::Blue),
281        ),
282        Span::styled(
283            "[n/Esc]",
284            Style::default()
285                .fg(Color::White)
286                .bg(Color::Red)
287                .add_modifier(Modifier::BOLD),
288        ),
289        Span::styled(
290            " Skip — execute directly ",
291            Style::default().fg(Color::White).bg(Color::Blue),
292        ),
293    ]);
294    let widget = Paragraph::new(line)
295        .style(Style::default().bg(Color::Blue))
296        .wrap(Wrap { trim: true });
297    frame.render_widget(widget, area);
298}