Skip to main content

stynx_code_tui/render/
renderer.rs

1use ratatui::Frame;
2use ratatui::style::Style;
3use ratatui::widgets::Block;
4
5use crate::layout::MainLayout;
6use crate::state::{AppState, ModalKind};
7use crate::theme;
8use crate::widgets::delegate_bar::DelegateBar;
9use crate::widgets::tool_detail::ToolDetail;
10use crate::widgets::tool_history::{flat_rows, HistoryRow, ToolHistory};
11use crate::widgets::{DialogSelect, Footer, InfoDialog, InputBox, InputDialog, MessageList, PermissionDialog, SidebarInfo, SlashPopover, SummaryBar, ThinkingPanel, ToastStack};
12
13pub struct Renderer;
14
15impl Renderer {
16    pub fn new() -> Self { Self }
17
18    pub fn draw(frame: &mut Frame, state: &mut AppState) {
19        let full = frame.area();
20        frame.render_widget(
21            Block::default().style(Style::default().bg(theme::BACKGROUND())),
22            full,
23        );
24
25        let thinking_lines = if state.is_streaming && !state.live_thinking.trim().is_empty() {
26            state
27                .live_thinking
28                .lines()
29                .filter(|l| !l.trim().is_empty())
30                .count()
31        } else {
32            0
33        };
34
35        let delegate_lines = state.sub_agents.len();
36        let has_summary = state.last_summary.is_some();
37        let summary_items: Option<Vec<String>> = state.last_summary.clone();
38        let layout = MainLayout::split(
39            full,
40            state.input.line_count(),
41            thinking_lines,
42            delegate_lines,
43            has_summary,
44        );
45
46        let tool_area = layout.tool_history;
47        if let Some(area) = tool_area {
48            frame.render_widget(ToolHistory::new(state), area);
49        }
50        if let Some(info_area) = layout.tool_info {
51            frame.render_widget(
52                SidebarInfo {
53                    cwd: &state.cwd,
54                    model: &state.model_name,
55                    mode: &state.permission_mode,
56                    cost: state.total_cost,
57                    git_branch: state.git_branch.as_deref(),
58                    is_streaming: state.is_streaming,
59                    is_pending: state.is_pending,
60                    is_paused: state.is_paused,
61                    spinner_frame: state.spinner_frame,
62                    elapsed_secs: state.elapsed_secs,
63                },
64                info_area,
65            );
66        }
67
68        frame.render_widget(
69            MessageList::new(&mut state.conversation, state.spinner_frame)
70                .with_tool_details(state.tool_details)
71                .with_thinking_active(state.is_streaming && !state.live_thinking.trim().is_empty()),
72            layout.messages,
73        );
74        if let Some(thinking_area) = layout.thinking {
75            frame.render_widget(
76                ThinkingPanel::new(&state.live_thinking, state.spinner_frame),
77                thinking_area,
78            );
79        }
80        if let Some(delegate_area) = layout.delegate {
81            frame.render_widget(
82                DelegateBar::new(&state.sub_agents, state.spinner_frame),
83                delegate_area,
84            );
85        }
86        if let (Some(summary_area), Some(items)) = (layout.summary, summary_items) {
87            frame.render_widget(SummaryBar::new(&items), summary_area);
88        }
89        frame.render_widget(InputBox::new(&state.input, !state.is_streaming), layout.input);
90        if !state.input.slash_matches.is_empty() {
91            frame.render_widget(SlashPopover::new(&state.input, layout.input), full);
92        }
93        if let Some(footer_area) = layout.footer {
94            frame.render_widget(
95                Footer {
96                    cwd: &state.cwd,
97                    model: &state.model_name,
98                    mode: &state.permission_mode,
99                    cost: state.total_cost,
100                    git_branch: state.git_branch.as_deref(),
101                    is_streaming: state.is_streaming,
102                    is_pending: state.is_pending,
103                    is_paused: state.is_paused,
104                    spinner_frame: state.spinner_frame,
105                    elapsed_secs: state.elapsed_secs,
106                },
107                footer_area,
108            );
109        }
110
111        frame.render_widget(ToastStack::new(&state.toasts), full);
112
113        if state.tool_history.detail_open {
114            if let Some(idx) = state.tool_history.selected {
115                let rows = flat_rows(state);
116                if let Some(row) = rows.get(idx) {
117                    let (mi, ti) = match row {
118                        HistoryRow::Tool { msg, tool } => (*msg, *tool),
119                        HistoryRow::Sub { msg, tool, .. } => (*msg, *tool),
120                    };
121                    let tool = &state.conversation.messages[mi].tool_uses[ti];
122                    frame.render_widget(ToolDetail::new(tool), full);
123                }
124            }
125        }
126
127        match &state.modal.active {
128            Some(ModalKind::Permission { tool_name, description, choice }) => {
129                // Constrain to the chat column width (matching the messages
130                // area) so the dialog doesn't span the whole terminal.
131                let chat = layout.messages;
132                let dialog_area = ratatui::layout::Rect {
133                    x: chat.x,
134                    y: full.y,
135                    width: chat.width,
136                    height: full.height,
137                };
138                frame.render_widget(
139                    PermissionDialog::new(tool_name, description, *choice),
140                    dialog_area,
141                );
142            }
143            Some(ModalKind::Select {
144                title,
145                query,
146                options,
147                selected,
148                current_value,
149                footer_hint,
150                ..
151            }) => {
152                frame.render_widget(
153                    DialogSelect::new(title, query, options, *selected)
154                        .with_current(current_value.as_deref())
155                        .with_footer(footer_hint.as_deref()),
156                    full,
157                );
158            }
159            Some(ModalKind::Info { title, rows }) => {
160                frame.render_widget(InfoDialog::new(title, rows), full);
161            }
162            Some(ModalKind::Input { title, prompt, buffer, .. }) => {
163                frame.render_widget(InputDialog::new(title, prompt, buffer), full);
164            }
165            Some(ModalKind::QuitConfirm) => {}
166            None => {}
167        }
168    }
169}
170
171impl Default for Renderer {
172    fn default() -> Self { Self }
173}