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