1pub mod diff;
18pub mod markdown;
19pub mod theme;
20pub mod widgets;
21
22use ratatui::{Frame, layout::Margin};
23use rustc_hash::FxHashMap;
24use unicode_width::UnicodeWidthChar;
25
26use crate::domain::{State, TurnState};
27use crate::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
28
29use widgets::{
30 AttachmentWidget, ChatState, ChatWidget, GenerationStatus, InputState, InputWidget,
31 SlashPaletteWidget, StatusWidget, build_status_lines,
32};
33
34pub struct RenderCache {
43 pub chat: ChatState,
44 pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
48 pub theme: theme::Theme,
49 pub hostname: String,
53 pub username: String,
54 last_mouse_scroll_accum: i32,
58}
59
60impl Default for RenderCache {
61 fn default() -> Self {
62 Self {
63 chat: ChatState::new(),
64 wrapped_line_cache: FxHashMap::default(),
65 theme: theme::Theme::dark(),
66 hostname: std::env::var("HOSTNAME")
67 .or_else(|_| std::env::var("HOST"))
68 .unwrap_or_else(|_| "localhost".to_string()),
69 username: std::env::var("USER")
70 .or_else(|_| std::env::var("USERNAME"))
71 .unwrap_or_else(|_| "user".to_string()),
72 last_mouse_scroll_accum: 0,
73 }
74 }
75}
76
77impl RenderCache {
78 pub fn new() -> Self {
79 Self::default()
80 }
81}
82
83pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
85 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
90 if pending > 0 {
91 rstate.chat.scroll_up(pending as u16);
92 } else if pending < 0 {
93 rstate.chat.scroll_down((-pending) as u16);
94 }
95 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
96
97 let terminal_width = frame.area().width.saturating_sub(4) as usize;
99 let input_lines = if state.ui.input_buffer.is_empty() {
100 1
101 } else {
102 let mut lines = 1usize;
103 let mut col = 0usize;
104 for ch in state.ui.input_buffer.chars() {
105 let w = ch.width().unwrap_or(0);
106 if ch == '\n' || col >= terminal_width {
107 lines += 1;
108 col = if ch == '\n' { 0 } else { w };
109 } else {
110 col += w;
111 }
112 }
113 lines.min(5)
114 };
115 let input_height = (input_lines + 2) as u16;
116
117 let status_lines = if state.is_busy() {
122 let now_sys = std::time::SystemTime::from(state.now);
126 let elapsed_since =
127 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
128 let elapsed_secs = match &state.turn {
129 TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
132 state
133 .runtime
134 .run_started
135 .map_or_else(|| elapsed_since(*started), elapsed_since)
136 },
137 TurnState::Compacting { started, .. } => elapsed_since(*started),
138 TurnState::Cancelling { since, .. } => elapsed_since(*since),
139 TurnState::Idle => 0,
140 };
141 let active_tool = match &state.turn {
144 TurnState::ExecutingTools {
145 calls, outcomes, ..
146 } => {
147 let pending = outcomes.iter().filter(|o| o.is_none()).count();
148 calls
149 .iter()
150 .zip(outcomes)
151 .find(|(_, o)| o.is_none())
152 .map(|(call, _)| {
153 let (action, target) = crate::domain::display_info_for(call);
154 let label = if target.is_empty() {
155 action
156 } else {
157 format!("{action} {target}")
158 };
159 if pending > 1 {
160 format!("{label} (+{} more)", pending - 1)
161 } else {
162 label
163 }
164 })
165 },
166 _ => None,
167 };
168 let committed = state.runtime.run_committed_tokens;
173 let (tokens_display, tokens_estimated) = match &state.turn {
174 TurnState::Generating { tokens, .. } => (committed + *tokens, true),
175 TurnState::ExecutingTools { .. } => (committed, true),
176 _ => (0, false),
177 };
178 build_status_lines(
179 GenerationStatus::from_turn(&state.turn),
180 elapsed_secs,
181 tokens_display,
182 tokens_estimated,
183 active_tool.as_deref(),
184 &state.ui.queued_messages,
185 &rstate.theme,
186 frame.area().width.saturating_sub(2),
188 )
189 } else {
190 Vec::new()
191 };
192
193 let attachment_height = if state.ui.attachments.is_empty() {
194 0
195 } else {
196 1
197 };
198
199 let status_banner_height: u16 = 0;
204
205 let status_reserve = 10 + input_height + 2 + status_banner_height + attachment_height;
211 let status_line_height = (status_lines.len() as u16)
212 .min(6)
213 .min(frame.area().height.saturating_sub(status_reserve));
214
215 let approval_item = state.pending_approval.front();
224 let confirm_open = approval_item.is_none() && state.confirm.is_some();
225 let conv_list_open = approval_item.is_none()
226 && !confirm_open
227 && matches!(
228 state.ui.mode,
229 crate::domain::UiMode::ConversationList { .. }
230 );
231 let palette_open = approval_item.is_none()
232 && !confirm_open
233 && !conv_list_open
234 && state.ui.input_buffer.starts_with('/');
235 let bottom_height = if let Some(item) = approval_item {
236 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
238 2 + body_lines + 1 + 3
239 } else if confirm_open {
240 6
241 } else if conv_list_open {
242 12
243 } else if palette_open {
244 let typed = state
245 .ui
246 .input_buffer
247 .trim_start_matches('/')
248 .split_whitespace()
249 .next()
250 .unwrap_or("");
251 let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
252 .len()
253 .clamp(1, 8);
254 (row_count as u16) + 2
255 } else {
256 2
257 };
258
259 use ratatui::layout::{Constraint, Direction, Layout};
264 let chunks = Layout::default()
265 .direction(Direction::Vertical)
266 .constraints([
267 Constraint::Min(10),
268 Constraint::Length(status_line_height),
269 Constraint::Length(attachment_height),
270 Constraint::Length(status_banner_height),
271 Constraint::Length(input_height),
272 Constraint::Length(bottom_height),
273 ])
274 .split(frame.area());
275
276 let chat_area = chunks[0].inner(Margin {
278 horizontal: 1,
279 vertical: 0,
280 });
281 let live_messages = build_live_messages(state.session.messages(), &state.turn, state.now);
282 let chat_widget = ChatWidget {
283 messages: live_messages.as_ref(),
284 theme: &rstate.theme,
285 wrapped_line_cache: &mut rstate.wrapped_line_cache,
286 show_reasoning: state.ui.show_reasoning,
287 };
288 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
289
290 if !status_lines.is_empty() {
293 let status_area = chunks[1].inner(Margin {
294 horizontal: 1,
295 vertical: 0,
296 });
297 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
298 }
299
300 if !state.ui.attachments.is_empty() {
302 let attachment_widget = AttachmentWidget {
303 attachments: &state.ui.attachments,
304 theme: &rstate.theme,
305 focused: state.ui.attachment_focused,
306 selected: state.ui.attachment_selected,
307 };
308 frame.render_widget(attachment_widget, chunks[2]);
309 }
310
311 let input_widget = InputWidget {
315 input: state.ui.input_buffer.as_str(),
316 showing_command_hints: state.ui.input_buffer.starts_with('/'),
317 theme: &rstate.theme,
318 reasoning_active: state.session.reasoning != ReasoningLevel::None,
319 };
320 let mut input_widget_state = InputState {
321 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
322 };
323 frame.render_stateful_widget(input_widget, chunks[4], &mut input_widget_state);
324
325 if !state.ui.attachment_focused {
327 let input_area = chunks[4];
328 let content_width = input_area.width.saturating_sub(2) as usize;
329 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
330 &state.ui.input_buffer,
331 state.ui.input_cursor.min(state.ui.input_buffer.len()),
332 content_width,
333 );
334 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
335 }
336
337 let requested = state.session.reasoning;
341 let effective = match supported_reasoning_for(state) {
342 Some(ReasoningCapability::Levels(supp)) => {
343 nearest_effort(requested, &supp).unwrap_or(requested)
344 },
345 _ => requested,
346 };
347 let requested_level = if effective == requested {
348 None
349 } else {
350 Some(requested)
351 };
352
353 if let Some(item) = state.pending_approval.front() {
356 use widgets::ApprovalModalWidget;
357 let options = if item.allowlist_scope.is_empty() {
361 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
362 } else {
363 vec![
364 "1. Yes".to_string(),
365 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
366 "3. No (Esc)".to_string(),
367 ]
368 };
369 let widget = ApprovalModalWidget {
370 theme: &rstate.theme,
371 title: format!("Approval required — {} [{}]", item.tool, item.risk),
372 body: item.prompt.as_str(),
373 options,
374 selected_index: Some(item.selected_option),
375 accent: rstate.theme.colors.warning.to_color(),
376 };
377 frame.render_widget(widget, chunks[5]);
378 } else if let Some(confirm) = &state.confirm {
379 use widgets::ApprovalModalWidget;
380 let widget = ApprovalModalWidget {
381 theme: &rstate.theme,
382 title: "Confirm".to_string(),
383 body: confirm.prompt.as_str(),
384 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
385 selected_index: None,
386 accent: rstate.theme.colors.warning.to_color(),
387 };
388 frame.render_widget(widget, chunks[5]);
389 } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
390 use widgets::ConversationListWidget;
391 let widget = ConversationListWidget {
392 theme: &rstate.theme,
393 candidates,
394 cursor: *cursor,
395 };
396 frame.render_widget(widget, chunks[5]);
397 } else if palette_open {
398 let typed = state
399 .ui
400 .input_buffer
401 .trim_start_matches('/')
402 .split_whitespace()
403 .next()
404 .unwrap_or("");
405 let commands = crate::domain::slash_commands::filter_by_prefix(typed);
406 let palette_widget = SlashPaletteWidget {
407 theme: &rstate.theme,
408 commands,
409 selected_index: state.ui.palette_cursor.unwrap_or(0),
410 };
411 frame.render_widget(palette_widget, chunks[5]);
412 } else {
413 let cwd = state.cwd.display().to_string();
414 let status_widget = StatusWidget {
415 theme: &rstate.theme,
416 working_dir: &cwd,
417 hostname: &rstate.hostname,
418 username: &rstate.username,
419 context_usage: state.session.context_usage.as_ref(),
420 last_usage: state.session.last_token_usage,
421 session_usage: state.session.cumulative_token_usage,
422 model_name: &state.session.model_id,
423 reasoning_level: effective,
424 requested_level,
425 safety_mode: state.session.safety_mode,
426 };
427 frame.render_widget(status_widget, chunks[5]);
428 }
429}
430
431fn build_live_messages<'a>(
435 committed: &'a [crate::models::ChatMessage],
436 turn: &TurnState,
437 now: chrono::DateTime<chrono::Local>,
438) -> std::borrow::Cow<'a, [crate::models::ChatMessage]> {
439 if let TurnState::Generating {
443 partial_text,
444 partial_reasoning,
445 ..
446 } = turn
447 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
448 {
449 let thinking = if partial_reasoning.is_empty() {
450 None
451 } else {
452 Some(partial_reasoning.clone())
453 };
454 let msg = crate::models::ChatMessage {
455 role: crate::models::MessageRole::Assistant,
456 content: partial_text.clone(),
457 timestamp: now,
460 kind: crate::models::ChatMessageKind::Normal,
461 metadata: None,
462 actions: Vec::new(),
463 thinking,
464 images: None,
465 tool_calls: None,
466 tool_call_id: None,
467 tool_name: None,
468 thinking_signature: None,
469 };
470 let mut out = committed.to_vec();
471 out.push(msg);
472 std::borrow::Cow::Owned(out)
473 } else {
474 std::borrow::Cow::Borrowed(committed)
475 }
476}
477
478fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
483 None
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::app::Config;
490 use crate::domain::{State, StatusKind, StatusLine, TurnState};
491 use ratatui::Terminal;
492 use ratatui::backend::TestBackend;
493 use std::path::PathBuf;
494
495 fn mock_state() -> State {
496 State::new(
497 Config::default(),
498 PathBuf::from("/tmp/p"),
499 "ollama/test".to_string(),
500 )
501 }
502
503 fn render_to_string(state: &State) -> String {
504 let backend = TestBackend::new(80, 24);
505 let mut terminal = Terminal::new(backend).expect("terminal");
506 let mut rstate = RenderCache::new();
507 terminal
508 .draw(|f| render(state, &mut rstate, f))
509 .expect("draw");
510 let buf = terminal.backend().buffer();
511 let mut out = String::new();
512 for y in 0..buf.area.height {
513 for x in 0..buf.area.width {
514 out.push_str(buf[(x, y)].symbol());
515 }
516 out.push('\n');
517 }
518 out
519 }
520
521 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
522 let backend = TestBackend::new(80, 24);
523 let mut terminal = Terminal::new(backend).expect("terminal");
524 let mut rstate = RenderCache::new();
525 terminal
526 .draw(|f| render(state, &mut rstate, f))
527 .expect("draw");
528 terminal.backend().buffer().clone()
529 }
530
531 #[test]
532 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
533 use crate::domain::{GenPhase, TurnId};
534 use crate::models::ChatMessage;
535 use std::borrow::Cow;
536 use std::time::SystemTime;
537
538 let committed = vec![ChatMessage::user("hi")];
539 let now = chrono::Local::now();
540
541 let idle = build_live_messages(&committed, &TurnState::Idle, now);
543 assert!(matches!(idle, Cow::Borrowed(_)));
544 assert_eq!(idle.len(), 1);
545
546 let turn = TurnState::Generating {
549 id: TurnId(1),
550 started: SystemTime::now(),
551 partial_text: "draft".to_string(),
552 partial_reasoning: String::new(),
553 tokens: 0,
554 phase: GenPhase::Sending,
555 thinking_signature: None,
556 pending_tool_calls: Vec::new(),
557 };
558 let live = build_live_messages(&committed, &turn, now);
559 assert!(matches!(live, Cow::Owned(_)));
560 assert_eq!(live.len(), 2);
561 assert_eq!(live[1].timestamp, now);
562 }
563
564 #[test]
565 fn user_prompt_renders_with_highlight_band() {
566 let mut s = mock_state();
567 s.session
568 .append(crate::models::ChatMessage::user("hello there"));
569 let buf = render_to_buffer(&s);
570 let band_bg = crate::render::theme::Theme::dark()
571 .colors
572 .user_message_background
573 .to_color();
574 let y = (0..buf.area.height)
576 .find(|&y| {
577 (0..buf.area.width)
578 .map(|x| buf[(x, y)].symbol())
579 .collect::<String>()
580 .contains("hello there")
581 })
582 .expect("user prompt should render");
583 let banded = (0..buf.area.width)
586 .filter(|&x| buf[(x, y)].bg == band_bg)
587 .count();
588 assert!(
589 banded >= (buf.area.width as usize) * 3 / 4,
590 "user prompt band should fill most of the row; only {banded}/{} cells banded",
591 buf.area.width
592 );
593 }
594
595 #[test]
596 fn idle_state_renders_cwd_and_model_footer() {
597 let s = mock_state();
598 let frame = render_to_string(&s);
599 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
601 assert!(frame.contains("ollama/test"));
602 }
603
604 #[test]
605 fn status_line_appears_during_generating() {
606 let mut s = mock_state();
607 s.turn = crate::domain::transition::start_generating(
608 crate::domain::TurnId(1),
609 std::time::SystemTime::now(),
610 );
611 let frame = render_to_string(&s);
612 assert!(
613 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
614 "expected generation status in frame"
615 );
616 }
617
618 #[test]
619 fn status_line_names_the_in_flight_tool() {
620 use crate::domain::PendingToolCall;
621 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
622 let mut s = mock_state();
623 let call = PendingToolCall {
624 call_id: crate::domain::ToolCallId(1),
625 source: ModelToolCall {
626 id: Some("c1".to_string()),
627 function: FunctionCall {
628 name: "execute_command".to_string(),
629 arguments: serde_json::json!({"command": "npm run dev"}),
630 },
631 },
632 };
633 s.turn = TurnState::ExecutingTools {
634 id: crate::domain::TurnId(1),
635 started: std::time::SystemTime::now(),
636 calls: vec![call],
637 outcomes: vec![None],
638 };
639 let frame = render_to_string(&s);
640 assert!(frame.contains("Running tools"), "got: {frame}");
641 assert!(
642 frame.contains("npm run dev"),
643 "status line must name the in-flight command; got: {frame}"
644 );
645 }
646
647 #[test]
648 fn status_line_appears_during_tool_execution_and_shows_queue() {
649 let mut s = mock_state();
650 s.turn = TurnState::ExecutingTools {
651 id: crate::domain::TurnId(1),
652 started: std::time::SystemTime::now(),
653 calls: Vec::new(),
654 outcomes: Vec::new(),
655 };
656 s.ui.queued_messages
657 .push_back(crate::domain::QueuedMessage {
658 text: "please steer this".to_string(),
659 attachment_ids: Vec::new(),
660 });
661 let frame = render_to_string(&s);
662 assert!(frame.contains("Running tools"), "expected tool status");
663 assert!(
664 frame.contains("please steer this"),
665 "queued busy input must be visible"
666 );
667 }
668
669 #[test]
670 fn reasoning_blocks_are_collapsed_by_default() {
671 let mut s = mock_state();
672 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
673 first_msg.thinking = Some("first private chain of thought".to_string());
674 s.session.append(first_msg);
675 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
676 second_msg.thinking = Some("second private chain of thought".to_string());
677 s.session.append(second_msg);
678 let frame = render_to_string(&s);
679 assert!(!frame.contains("Reasoning hidden"));
681 assert!(frame.contains("first visible answer"));
682 assert!(frame.contains("second visible answer"));
683 assert!(!frame.contains("first private chain of thought"));
684 assert!(!frame.contains("second private chain of thought"));
685 }
686
687 #[test]
691 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
692 let mut s = mock_state();
693 let mut msg = crate::models::ChatMessage::assistant("");
694 msg.thinking = Some("private chain of thought".to_string());
695 msg.actions.push(crate::domain::ActionDisplay {
696 action_type: "Bash".to_string(),
697 target: "dir".to_string(),
698 result: crate::domain::ActionResult::Success {
699 output: "ok".to_string(),
700 images: None,
701 },
702 details: crate::domain::ActionDetails::Simple,
703 duration_seconds: Some(0.015),
704 metadata: None,
705 });
706 s.session.append(msg);
707 let frame = render_to_string(&s);
708 assert!(
709 !frame.contains("Reasoning hidden"),
710 "no reasoning-hidden placeholder"
711 );
712 assert!(
713 frame.contains("Bash"),
714 "the action still renders even though reasoning is hidden"
715 );
716 }
717
718 #[test]
719 fn committed_message_appears_in_chat_pane() {
720 let mut s = mock_state();
721 s.session
722 .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
723 let frame = render_to_string(&s);
724 assert!(frame.contains("unique-user-token-xyz"));
725 }
726
727 #[test]
728 fn palette_renders_when_input_starts_with_slash() {
729 let mut s = mock_state();
730 s.ui.input_buffer = "/help".to_string();
731 s.ui.input_cursor = 5;
732 let frame = render_to_string(&s);
733 assert!(frame.contains("help"));
735 }
736
737 #[test]
738 fn status_line_helper_maps_idle_to_idle() {
739 assert_eq!(
740 GenerationStatus::from_turn(&TurnState::Idle),
741 GenerationStatus::Idle
742 );
743 }
744
745 #[test]
749 fn state_status_is_never_painted() {
750 let mut s = mock_state();
751 s.status = Some(StatusLine {
752 text: "Reasoning: high".to_string(),
753 kind: StatusKind::Info,
754 shown_at: std::time::SystemTime::now(),
755 });
756 let frame = render_to_string(&s);
757 assert!(
758 !frame.contains("Reasoning: high"),
759 "the status banner is gone; state.status must not reach the screen"
760 );
761 }
762}