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, StatusWidget, build_status_lines,
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 status_lines = if state.is_busy() {
109 let elapsed_secs = match &state.turn {
110 TurnState::Generating { started, .. } | TurnState::Compacting { started, .. } => {
111 started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
112 },
113 TurnState::Cancelling { since, .. } => {
114 since.elapsed().map(|d| d.as_secs()).unwrap_or(0)
115 },
116 TurnState::ExecutingTools { started, .. } => {
117 started.elapsed().map(|d| d.as_secs()).unwrap_or(0)
118 },
119 TurnState::Idle => 0,
120 };
121 let active_tool = match &state.turn {
124 TurnState::ExecutingTools {
125 calls, outcomes, ..
126 } => {
127 let pending = outcomes.iter().filter(|o| o.is_none()).count();
128 calls
129 .iter()
130 .zip(outcomes)
131 .find(|(_, o)| o.is_none())
132 .map(|(call, _)| {
133 let (action, target) = crate::domain::display_info_for(call);
134 let label = if target.is_empty() {
135 action
136 } else {
137 format!("{action} {target}")
138 };
139 if pending > 1 {
140 format!("{label} (+{} more)", pending - 1)
141 } else {
142 label
143 }
144 })
145 },
146 _ => None,
147 };
148 let (tokens_display, tokens_estimated) = match &state.turn {
149 TurnState::Generating {
150 tokens,
151 partial_text,
152 ..
153 } if *tokens == 0 && !partial_text.is_empty() => (partial_text.len() / 4, true),
154 TurnState::Generating { tokens, .. } => (*tokens, false),
155 _ => (0, false),
156 };
157 build_status_lines(
158 GenerationStatus::from_turn(&state.turn),
159 elapsed_secs,
160 tokens_display,
161 tokens_estimated,
162 active_tool.as_deref(),
163 &state.ui.queued_messages,
164 &rstate.theme,
165 frame.area().width.saturating_sub(2),
167 )
168 } else {
169 Vec::new()
170 };
171
172 let attachment_height = if state.ui.attachments.is_empty() {
173 0
174 } else {
175 1
176 };
177
178 let status_banner_height: u16 = if state.status.is_some() { 1 } else { 0 };
183
184 let status_reserve = 10 + input_height + 2 + status_banner_height + attachment_height;
190 let status_line_height = (status_lines.len() as u16)
191 .min(6)
192 .min(frame.area().height.saturating_sub(status_reserve));
193
194 let approval_item = state.pending_approval.front();
203 let confirm_open = approval_item.is_none() && state.confirm.is_some();
204 let conv_list_open = approval_item.is_none()
205 && !confirm_open
206 && matches!(
207 state.ui.mode,
208 crate::domain::UiMode::ConversationList { .. }
209 );
210 let palette_open = approval_item.is_none()
211 && !confirm_open
212 && !conv_list_open
213 && state.ui.input_buffer.starts_with('/');
214 let bottom_height = if let Some(item) = approval_item {
215 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
217 2 + body_lines + 1 + 3
218 } else if confirm_open {
219 6
220 } else if conv_list_open {
221 12
222 } else if palette_open {
223 let typed = state
224 .ui
225 .input_buffer
226 .trim_start_matches('/')
227 .split_whitespace()
228 .next()
229 .unwrap_or("");
230 let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
231 .len()
232 .clamp(1, 8);
233 (row_count as u16) + 2
234 } else {
235 2
236 };
237
238 use ratatui::layout::{Constraint, Direction, Layout};
243 let chunks = Layout::default()
244 .direction(Direction::Vertical)
245 .constraints([
246 Constraint::Min(10),
247 Constraint::Length(status_line_height),
248 Constraint::Length(attachment_height),
249 Constraint::Length(status_banner_height),
250 Constraint::Length(input_height),
251 Constraint::Length(bottom_height),
252 ])
253 .split(frame.area());
254
255 let chat_area = chunks[0].inner(Margin {
257 horizontal: 1,
258 vertical: 0,
259 });
260 let committed = state.session.messages().to_vec();
261 let live_messages = build_live_messages(&committed, &state.turn);
262 let chat_widget = ChatWidget {
263 messages: &live_messages,
264 theme: &rstate.theme,
265 markdown_cache: &mut rstate.markdown_cache,
266 show_reasoning: state.ui.show_reasoning,
267 };
268 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
269
270 if !status_lines.is_empty() {
273 let status_area = chunks[1].inner(Margin {
274 horizontal: 1,
275 vertical: 0,
276 });
277 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
278 }
279
280 if !state.ui.attachments.is_empty() {
282 let attachment_widget = AttachmentWidget {
283 attachments: &state.ui.attachments,
284 theme: &rstate.theme,
285 focused: state.ui.attachment_focused,
286 selected: state.ui.attachment_selected,
287 };
288 frame.render_widget(attachment_widget, chunks[2]);
289 }
290
291 if let Some(ref status) = state.status {
293 let banner = widgets::StatusBannerWidget {
294 theme: &rstate.theme,
295 status,
296 };
297 frame.render_widget(banner, chunks[3]);
298 }
299
300 let input_widget = InputWidget {
302 input: state.ui.input_buffer.as_str(),
303 showing_command_hints: state.ui.input_buffer.starts_with('/'),
304 theme: &rstate.theme,
305 reasoning_active: state.session.reasoning != ReasoningLevel::None,
306 };
307 let mut input_widget_state = InputState {
308 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
309 };
310 frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
311
312 if !state.ui.attachment_focused {
314 let input_area = chunks[4];
315 let content_width = input_area.width.saturating_sub(2) as usize;
316 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
317 &state.ui.input_buffer,
318 state.ui.input_cursor.min(state.ui.input_buffer.len()),
319 content_width,
320 );
321 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
322 }
323
324 let requested = state.session.reasoning;
328 let effective = match supported_reasoning_for(state) {
329 Some(ReasoningCapability::Levels(supp)) => {
330 nearest_effort(requested, &supp).unwrap_or(requested)
331 },
332 _ => requested,
333 };
334 let requested_level = if effective == requested {
335 None
336 } else {
337 Some(requested)
338 };
339
340 if let Some(item) = state.pending_approval.front() {
343 use widgets::ApprovalModalWidget;
344 let widget = ApprovalModalWidget {
345 theme: &rstate.theme,
346 title: format!("Approval required — {} [{}]", item.tool, item.risk),
347 body: item.prompt.as_str(),
348 options: vec![
349 "1. Yes".to_string(),
350 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
351 "3. No (Esc)".to_string(),
352 ],
353 selected_index: Some(item.selected_option),
354 accent: rstate.theme.colors.warning.to_color(),
355 };
356 frame.render_widget(widget, chunks[5]);
357 } else if let Some(confirm) = &state.confirm {
358 use widgets::ApprovalModalWidget;
359 let widget = ApprovalModalWidget {
360 theme: &rstate.theme,
361 title: "Confirm".to_string(),
362 body: confirm.prompt.as_str(),
363 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
364 selected_index: None,
365 accent: rstate.theme.colors.warning.to_color(),
366 };
367 frame.render_widget(widget, chunks[5]);
368 } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
369 use widgets::ConversationListWidget;
370 let widget = ConversationListWidget {
371 theme: &rstate.theme,
372 candidates,
373 cursor: *cursor,
374 };
375 frame.render_widget(widget, chunks[5]);
376 } else if palette_open {
377 let typed = state
378 .ui
379 .input_buffer
380 .trim_start_matches('/')
381 .split_whitespace()
382 .next()
383 .unwrap_or("");
384 let commands = crate::domain::slash_commands::filter_by_prefix(typed);
385 let palette_widget = SlashPaletteWidget {
386 theme: &rstate.theme,
387 commands,
388 selected_index: state.ui.palette_cursor.unwrap_or(0),
389 };
390 frame.render_widget(palette_widget, chunks[5]);
391 } else {
392 let cwd = state.cwd.display().to_string();
393 let status_widget = StatusWidget {
394 theme: &rstate.theme,
395 working_dir: &cwd,
396 context_usage: state.session.context_usage.as_ref(),
397 last_usage: state.session.last_token_usage,
398 session_usage: state.session.cumulative_token_usage,
399 model_name: &state.session.model_id,
400 reasoning_level: effective,
401 requested_level,
402 safety_mode: state.session.safety_mode,
403 };
404 frame.render_widget(status_widget, chunks[5]);
405 }
406}
407
408fn build_live_messages(
412 committed: &[crate::models::ChatMessage],
413 turn: &TurnState,
414) -> Vec<crate::models::ChatMessage> {
415 let mut out = committed.to_vec();
416 if let TurnState::Generating {
417 partial_text,
418 partial_reasoning,
419 ..
420 } = turn
421 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
422 {
423 let thinking = if partial_reasoning.is_empty() {
424 None
425 } else {
426 Some(partial_reasoning.clone())
427 };
428 let msg = crate::models::ChatMessage {
429 role: crate::models::MessageRole::Assistant,
430 content: partial_text.clone(),
431 timestamp: chrono::Local::now(),
432 kind: crate::models::ChatMessageKind::Normal,
433 metadata: None,
434 actions: Vec::new(),
435 thinking,
436 images: None,
437 tool_calls: None,
438 tool_call_id: None,
439 tool_name: None,
440 thinking_signature: None,
441 };
442 out.push(msg);
443 }
444 out
445}
446
447fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
452 None
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use crate::app::Config;
459 use crate::domain::{State, StatusKind, StatusLine, TurnState};
460 use ratatui::Terminal;
461 use ratatui::backend::TestBackend;
462 use std::path::PathBuf;
463
464 fn mock_state() -> State {
465 State::new(
466 Config::default(),
467 PathBuf::from("/tmp/p"),
468 "ollama/test".to_string(),
469 )
470 }
471
472 fn render_to_string(state: &State) -> String {
473 let backend = TestBackend::new(80, 24);
474 let mut terminal = Terminal::new(backend).expect("terminal");
475 let mut rstate = RenderCache::new();
476 terminal
477 .draw(|f| render(state, &mut rstate, f))
478 .expect("draw");
479 let buf = terminal.backend().buffer();
480 let mut out = String::new();
481 for y in 0..buf.area.height {
482 for x in 0..buf.area.width {
483 out.push_str(buf[(x, y)].symbol());
484 }
485 out.push('\n');
486 }
487 out
488 }
489
490 #[test]
491 fn idle_state_renders_cwd_and_model_footer() {
492 let s = mock_state();
493 let frame = render_to_string(&s);
494 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
496 assert!(frame.contains("ollama/test"));
497 }
498
499 #[test]
500 fn status_line_appears_during_generating() {
501 let mut s = mock_state();
502 s.turn = crate::domain::transition::start_generating(crate::domain::TurnId(1));
503 let frame = render_to_string(&s);
504 assert!(
505 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
506 "expected generation status in frame"
507 );
508 }
509
510 #[test]
511 fn status_line_names_the_in_flight_tool() {
512 use crate::domain::PendingToolCall;
513 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
514 let mut s = mock_state();
515 let call = PendingToolCall {
516 call_id: crate::domain::ToolCallId(1),
517 source: ModelToolCall {
518 id: Some("c1".to_string()),
519 function: FunctionCall {
520 name: "execute_command".to_string(),
521 arguments: serde_json::json!({"command": "npm run dev"}),
522 },
523 },
524 };
525 s.turn = TurnState::ExecutingTools {
526 id: crate::domain::TurnId(1),
527 started: std::time::SystemTime::now(),
528 calls: vec![call],
529 outcomes: vec![None],
530 };
531 let frame = render_to_string(&s);
532 assert!(frame.contains("Running tools"), "got: {frame}");
533 assert!(
534 frame.contains("npm run dev"),
535 "status line must name the in-flight command; got: {frame}"
536 );
537 }
538
539 #[test]
540 fn status_line_appears_during_tool_execution_and_shows_queue() {
541 let mut s = mock_state();
542 s.turn = TurnState::ExecutingTools {
543 id: crate::domain::TurnId(1),
544 started: std::time::SystemTime::now(),
545 calls: Vec::new(),
546 outcomes: Vec::new(),
547 };
548 s.ui.queued_messages
549 .push_back("please steer this".to_string());
550 let frame = render_to_string(&s);
551 assert!(frame.contains("Running tools"), "expected tool status");
552 assert!(
553 frame.contains("please steer this"),
554 "queued busy input must be visible"
555 );
556 }
557
558 #[test]
559 fn reasoning_blocks_are_collapsed_by_default() {
560 let mut s = mock_state();
561 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
562 first_msg.thinking = Some("first private chain of thought".to_string());
563 s.session.append(first_msg);
564 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
565 second_msg.thinking = Some("second private chain of thought".to_string());
566 s.session.append(second_msg);
567 let frame = render_to_string(&s);
568 assert_eq!(frame.matches("Reasoning hidden").count(), 1);
569 assert!(frame.contains("first visible answer"));
570 assert!(frame.contains("second visible answer"));
571 assert!(!frame.contains("first private chain of thought"));
572 assert!(!frame.contains("second private chain of thought"));
573 }
574
575 #[test]
581 fn reasoning_placeholder_is_gapped_from_following_action() {
582 let mut s = mock_state();
583 let mut msg = crate::models::ChatMessage::assistant("");
584 msg.thinking = Some("private chain of thought".to_string());
585 msg.actions.push(crate::domain::ActionDisplay {
586 action_type: "Bash".to_string(),
587 target: "dir".to_string(),
588 result: crate::domain::ActionResult::Success {
589 output: "ok".to_string(),
590 images: None,
591 },
592 details: crate::domain::ActionDetails::Simple,
593 duration_seconds: Some(0.015),
594 metadata: None,
595 });
596 s.session.append(msg);
597 let frame = render_to_string(&s);
598 let lines: Vec<&str> = frame.lines().collect();
599 let idx = lines
600 .iter()
601 .position(|l| l.contains("Reasoning hidden"))
602 .expect("placeholder must render");
603 assert!(
604 lines[idx + 1].trim().is_empty(),
605 "a blank line must separate the placeholder from the action; got {:?}",
606 &lines[idx..=(idx + 2).min(lines.len() - 1)]
607 );
608 assert!(
609 lines[idx..].iter().any(|l| l.contains("Bash")),
610 "the action must still render after the placeholder"
611 );
612 }
613
614 #[test]
615 fn scrollbar_shows_when_chat_overflows() {
616 let mut s = mock_state();
617 for i in 0..60 {
618 s.session
619 .append(crate::models::ChatMessage::assistant(format!("msg {i}")));
620 }
621 let frame = render_to_string(&s);
622 assert!(
625 frame.contains('█'),
626 "a scrollbar thumb should render when the transcript overflows the viewport"
627 );
628 }
629
630 #[test]
631 fn committed_message_appears_in_chat_pane() {
632 let mut s = mock_state();
633 s.session
634 .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
635 let frame = render_to_string(&s);
636 assert!(frame.contains("unique-user-token-xyz"));
637 }
638
639 #[test]
640 fn palette_renders_when_input_starts_with_slash() {
641 let mut s = mock_state();
642 s.ui.input_buffer = "/help".to_string();
643 s.ui.input_cursor = 5;
644 let frame = render_to_string(&s);
645 assert!(frame.contains("help"));
647 }
648
649 #[test]
650 fn status_line_helper_maps_idle_to_idle() {
651 assert_eq!(
652 GenerationStatus::from_turn(&TurnState::Idle),
653 GenerationStatus::Idle
654 );
655 }
656
657 #[test]
661 fn state_status_renders_as_banner() {
662 let mut s = mock_state();
663 s.status = Some(StatusLine {
664 text: "Reasoning: high".to_string(),
665 kind: StatusKind::Info,
666 shown_at: std::time::SystemTime::now(),
667 });
668 let frame = render_to_string(&s);
669 assert!(
670 frame.contains("Reasoning: high"),
671 "state.status must reach the screen"
672 );
673 }
674
675 #[test]
676 fn unused_status_line_struct_silences_warning() {
677 let _ = StatusLine {
679 text: "x".to_string(),
680 kind: StatusKind::Info,
681 shown_at: std::time::SystemTime::now(),
682 };
683 }
684}