1pub mod diff;
18pub mod layout;
19pub mod markdown;
20pub mod theme;
21pub mod widgets;
22
23use ratatui::{Frame, layout::Margin};
24use rustc_hash::FxHashMap;
25use unicode_width::UnicodeWidthChar;
26
27use crate::domain::{State, TurnState};
28use crate::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
29
30use widgets::{
31 AttachmentWidget, ChatState, ChatWidget, GenerationStatus, InputState, InputWidget,
32 SlashPaletteWidget, StatusLineWidget, StatusWidget,
33};
34
35pub struct RenderCache {
44 pub chat: ChatState,
45 pub markdown_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
46 pub theme: theme::Theme,
47 last_mouse_scroll_accum: i32,
51}
52
53impl Default for RenderCache {
54 fn default() -> Self {
55 Self {
56 chat: ChatState::new(),
57 markdown_cache: FxHashMap::default(),
58 theme: theme::Theme::dark(),
59 last_mouse_scroll_accum: 0,
60 }
61 }
62}
63
64impl RenderCache {
65 pub fn new() -> Self {
66 Self::default()
67 }
68}
69
70pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
72 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
77 if pending > 0 {
78 rstate.chat.scroll_up(pending as u16);
79 } else if pending < 0 {
80 rstate.chat.scroll_down((-pending) as u16);
81 }
82 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
83
84 let terminal_width = frame.area().width.saturating_sub(4) as usize;
86 let input_lines = if state.ui.input_buffer.is_empty() {
87 1
88 } else {
89 let mut lines = 1usize;
90 let mut col = 0usize;
91 for ch in state.ui.input_buffer.chars() {
92 let w = ch.width().unwrap_or(0);
93 if ch == '\n' || col >= terminal_width {
94 lines += 1;
95 col = if ch == '\n' { 0 } else { w };
96 } else {
97 col += w;
98 }
99 }
100 lines.min(5)
101 };
102 let input_height = (input_lines + 2) as u16;
103
104 let queued_count = state.ui.queued_messages.len();
105 let status_line_height = if state.is_busy() {
106 (1 + queued_count).min(6) as u16
107 } else {
108 0
109 };
110
111 let attachment_height = if state.ui.attachments.is_empty() {
112 0
113 } else {
114 1
115 };
116
117 let status_banner_height: u16 = if state.status.is_some() { 1 } else { 0 };
122
123 let conv_list_open = matches!(
129 state.ui.mode,
130 crate::domain::UiMode::ConversationList { .. }
131 );
132 let palette_open = !conv_list_open && state.ui.input_buffer.starts_with('/');
133 let bottom_height = if conv_list_open {
134 12
135 } else if palette_open {
136 let typed = state
137 .ui
138 .input_buffer
139 .trim_start_matches('/')
140 .split_whitespace()
141 .next()
142 .unwrap_or("");
143 let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
144 .len()
145 .clamp(1, 8);
146 (row_count as u16) + 2
147 } else {
148 2
149 };
150
151 use ratatui::layout::{Constraint, Direction, Layout};
156 let chunks = Layout::default()
157 .direction(Direction::Vertical)
158 .constraints([
159 Constraint::Min(10),
160 Constraint::Length(status_line_height),
161 Constraint::Length(attachment_height),
162 Constraint::Length(status_banner_height),
163 Constraint::Length(input_height),
164 Constraint::Length(bottom_height),
165 ])
166 .split(frame.area());
167
168 let chat_area = chunks[0].inner(Margin {
170 horizontal: 1,
171 vertical: 0,
172 });
173 let committed = state.session.messages().to_vec();
174 let live_messages = build_live_messages(&committed, &state.turn);
175 let chat_widget = ChatWidget {
176 messages: &live_messages,
177 theme: &rstate.theme,
178 markdown_cache: &mut rstate.markdown_cache,
179 show_reasoning: state.ui.show_reasoning,
180 };
181 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
182
183 if state.is_busy() {
187 let elapsed_secs = match &state.turn {
188 TurnState::Generating { started, .. } | TurnState::Compacting { started, .. } => {
189 started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
190 },
191 TurnState::Cancelling { since, .. } => {
192 since.elapsed().map(|d| d.as_secs()).unwrap_or(0)
193 },
194 TurnState::ExecutingTools { .. } | TurnState::Idle => 0,
195 };
196 let (tokens_display, tokens_estimated) = match &state.turn {
197 TurnState::Generating {
198 tokens,
199 partial_text,
200 ..
201 } if *tokens == 0 && !partial_text.is_empty() => (partial_text.len() / 4, true),
202 TurnState::Generating { tokens, .. } => (*tokens, false),
203 _ => (0, false),
204 };
205 let status_line_widget = StatusLineWidget {
206 status: GenerationStatus::from_turn(&state.turn),
207 elapsed_secs,
208 tokens_received: tokens_display,
209 tokens_estimated,
210 theme: &rstate.theme,
211 queued_messages: &state.ui.queued_messages,
212 };
213 frame.render_widget(status_line_widget, chunks[1]);
214 }
215
216 if !state.ui.attachments.is_empty() {
218 let attachment_widget = AttachmentWidget {
219 attachments: &state.ui.attachments,
220 theme: &rstate.theme,
221 focused: state.ui.attachment_focused,
222 selected: state.ui.attachment_selected,
223 };
224 frame.render_widget(attachment_widget, chunks[2]);
225 }
226
227 if let Some(ref status) = state.status {
229 let banner = widgets::StatusBannerWidget {
230 theme: &rstate.theme,
231 status,
232 };
233 frame.render_widget(banner, chunks[3]);
234 }
235
236 let input_widget = InputWidget {
238 input: state.ui.input_buffer.as_str(),
239 showing_command_hints: state.ui.input_buffer.starts_with('/'),
240 theme: &rstate.theme,
241 reasoning_active: state.session.reasoning != ReasoningLevel::None,
242 };
243 let mut input_widget_state = InputState {
244 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
245 };
246 frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
247
248 if !state.ui.attachment_focused {
250 let input_area = chunks[4];
251 let content_width = input_area.width.saturating_sub(2) as usize;
252 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
253 &state.ui.input_buffer,
254 state.ui.input_cursor.min(state.ui.input_buffer.len()),
255 content_width,
256 );
257 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
258 }
259
260 let requested = state.session.reasoning;
264 let effective = match supported_reasoning_for(state) {
265 Some(ReasoningCapability::Levels(supp)) => {
266 nearest_effort(requested, &supp).unwrap_or(requested)
267 },
268 _ => requested,
269 };
270 let requested_level = if effective == requested {
271 None
272 } else {
273 Some(requested)
274 };
275
276 if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
279 use widgets::ConversationListWidget;
280 let widget = ConversationListWidget {
281 theme: &rstate.theme,
282 candidates,
283 cursor: *cursor,
284 };
285 frame.render_widget(widget, chunks[5]);
286 } else if palette_open {
287 let typed = state
288 .ui
289 .input_buffer
290 .trim_start_matches('/')
291 .split_whitespace()
292 .next()
293 .unwrap_or("");
294 let commands = crate::domain::slash_commands::filter_by_prefix(typed);
295 let palette_widget = SlashPaletteWidget {
296 theme: &rstate.theme,
297 commands,
298 selected_index: state.ui.palette_cursor.unwrap_or(0),
299 };
300 frame.render_widget(palette_widget, chunks[5]);
301 } else {
302 let cwd = state.cwd.display().to_string();
303 let status_widget = StatusWidget {
304 theme: &rstate.theme,
305 working_dir: &cwd,
306 context_usage: state.session.context_usage.as_ref(),
307 last_usage: state.session.last_token_usage,
308 session_usage: state.session.cumulative_token_usage,
309 model_name: &state.session.model_id,
310 reasoning_level: effective,
311 requested_level,
312 safety_mode: state.session.safety_mode,
313 };
314 frame.render_widget(status_widget, chunks[5]);
315 }
316}
317
318fn build_live_messages(
322 committed: &[crate::models::ChatMessage],
323 turn: &TurnState,
324) -> Vec<crate::models::ChatMessage> {
325 let mut out = committed.to_vec();
326 if let TurnState::Generating {
327 partial_text,
328 partial_reasoning,
329 ..
330 } = turn
331 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
332 {
333 let thinking = if partial_reasoning.is_empty() {
334 None
335 } else {
336 Some(partial_reasoning.clone())
337 };
338 let msg = crate::models::ChatMessage {
339 role: crate::models::MessageRole::Assistant,
340 content: partial_text.clone(),
341 timestamp: chrono::Local::now(),
342 kind: crate::models::ChatMessageKind::Normal,
343 metadata: None,
344 actions: Vec::new(),
345 thinking,
346 images: None,
347 tool_calls: None,
348 tool_call_id: None,
349 tool_name: None,
350 thinking_signature: None,
351 };
352 out.push(msg);
353 }
354 out
355}
356
357fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
362 None
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::app::Config;
369 use crate::domain::{State, StatusKind, StatusLine, TurnState};
370 use ratatui::Terminal;
371 use ratatui::backend::TestBackend;
372 use std::path::PathBuf;
373
374 fn mock_state() -> State {
375 State::new(
376 Config::default(),
377 PathBuf::from("/tmp/p"),
378 "ollama/test".to_string(),
379 )
380 }
381
382 fn render_to_string(state: &State) -> String {
383 let backend = TestBackend::new(80, 24);
384 let mut terminal = Terminal::new(backend).expect("terminal");
385 let mut rstate = RenderCache::new();
386 terminal
387 .draw(|f| render(state, &mut rstate, f))
388 .expect("draw");
389 let buf = terminal.backend().buffer();
390 let mut out = String::new();
391 for y in 0..buf.area.height {
392 for x in 0..buf.area.width {
393 out.push_str(buf[(x, y)].symbol());
394 }
395 out.push('\n');
396 }
397 out
398 }
399
400 #[test]
401 fn idle_state_renders_cwd_and_model_footer() {
402 let s = mock_state();
403 let frame = render_to_string(&s);
404 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
406 assert!(frame.contains("ollama/test"));
407 }
408
409 #[test]
410 fn status_line_appears_during_generating() {
411 let mut s = mock_state();
412 s.turn = crate::domain::transition::start_generating(crate::domain::TurnId(1));
413 let frame = render_to_string(&s);
414 assert!(
415 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
416 "expected generation status in frame"
417 );
418 }
419
420 #[test]
421 fn status_line_appears_during_tool_execution_and_shows_queue() {
422 let mut s = mock_state();
423 s.turn = TurnState::ExecutingTools {
424 id: crate::domain::TurnId(1),
425 calls: Vec::new(),
426 outcomes: Vec::new(),
427 };
428 s.ui.queued_messages
429 .push_back("please steer this".to_string());
430 let frame = render_to_string(&s);
431 assert!(frame.contains("Running tools"), "expected tool status");
432 assert!(
433 frame.contains("please steer this"),
434 "queued busy input must be visible"
435 );
436 }
437
438 #[test]
439 fn reasoning_blocks_are_collapsed_by_default() {
440 let mut s = mock_state();
441 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
442 first_msg.thinking = Some("first private chain of thought".to_string());
443 s.session.append(first_msg);
444 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
445 second_msg.thinking = Some("second private chain of thought".to_string());
446 s.session.append(second_msg);
447 let frame = render_to_string(&s);
448 assert_eq!(frame.matches("Reasoning hidden").count(), 1);
449 assert!(frame.contains("first visible answer"));
450 assert!(frame.contains("second visible answer"));
451 assert!(!frame.contains("first private chain of thought"));
452 assert!(!frame.contains("second private chain of thought"));
453 }
454
455 #[test]
456 fn committed_message_appears_in_chat_pane() {
457 let mut s = mock_state();
458 s.session
459 .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
460 let frame = render_to_string(&s);
461 assert!(frame.contains("unique-user-token-xyz"));
462 }
463
464 #[test]
465 fn palette_renders_when_input_starts_with_slash() {
466 let mut s = mock_state();
467 s.ui.input_buffer = "/help".to_string();
468 s.ui.input_cursor = 5;
469 let frame = render_to_string(&s);
470 assert!(frame.contains("help"));
472 }
473
474 #[test]
475 fn status_line_helper_maps_idle_to_idle() {
476 assert_eq!(
477 GenerationStatus::from_turn(&TurnState::Idle),
478 GenerationStatus::Idle
479 );
480 }
481
482 #[test]
486 fn state_status_renders_as_banner() {
487 let mut s = mock_state();
488 s.status = Some(StatusLine {
489 text: "Reasoning: high".to_string(),
490 kind: StatusKind::Info,
491 shown_at: std::time::SystemTime::now(),
492 });
493 let frame = render_to_string(&s);
494 assert!(
495 frame.contains("Reasoning: high"),
496 "state.status must reach the screen"
497 );
498 }
499
500 #[test]
501 fn unused_status_line_struct_silences_warning() {
502 let _ = StatusLine {
504 text: "x".to_string(),
505 kind: StatusKind::Info,
506 shown_at: std::time::SystemTime::now(),
507 };
508 }
509}