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_reserve = 10 + input_height + 2 + attachment_height;
205 let status_line_height = (status_lines.len() as u16)
206 .min(6)
207 .min(frame.area().height.saturating_sub(status_reserve));
208
209 let approval_item = state.pending_approval.front();
218 let confirm_open = approval_item.is_none() && state.confirm.is_some();
219 let conv_list_open = approval_item.is_none()
220 && !confirm_open
221 && matches!(
222 state.ui.mode,
223 crate::domain::UiMode::ConversationList { .. }
224 );
225 let palette_open = approval_item.is_none()
226 && !confirm_open
227 && !conv_list_open
228 && state.ui.input_buffer.starts_with('/');
229 let bottom_height = if let Some(item) = approval_item {
230 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
232 2 + body_lines + 1 + 3
233 } else if confirm_open {
234 6
235 } else if conv_list_open {
236 12
237 } else if palette_open {
238 let typed = state
239 .ui
240 .input_buffer
241 .trim_start_matches('/')
242 .split_whitespace()
243 .next()
244 .unwrap_or("");
245 let row_count = crate::domain::slash_commands::filter_by_prefix(typed)
246 .len()
247 .clamp(1, 8);
248 (row_count as u16) + 2
249 } else {
250 2
251 };
252
253 use ratatui::layout::{Constraint, Direction, Layout};
255 let chunks = Layout::default()
256 .direction(Direction::Vertical)
257 .constraints([
258 Constraint::Min(10),
259 Constraint::Length(status_line_height),
260 Constraint::Length(attachment_height),
261 Constraint::Length(input_height),
262 Constraint::Length(bottom_height),
263 ])
264 .split(frame.area());
265
266 let chat_area = chunks[0].inner(Margin {
268 horizontal: 1,
269 vertical: 0,
270 });
271 let live_messages = build_live_messages(state.session.messages(), &state.turn, state.now);
272 let chat_widget = ChatWidget {
273 messages: live_messages.as_ref(),
274 theme: &rstate.theme,
275 wrapped_line_cache: &mut rstate.wrapped_line_cache,
276 show_reasoning: state.ui.show_reasoning,
277 };
278 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
279
280 if !status_lines.is_empty() {
283 let status_area = chunks[1].inner(Margin {
284 horizontal: 1,
285 vertical: 0,
286 });
287 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
288 }
289
290 if !state.ui.attachments.is_empty() {
292 let attachment_widget = AttachmentWidget {
293 attachments: &state.ui.attachments,
294 theme: &rstate.theme,
295 focused: state.ui.attachment_focused,
296 selected: state.ui.attachment_selected,
297 };
298 frame.render_widget(attachment_widget, chunks[2]);
299 }
300
301 let input_widget = InputWidget {
303 input: state.ui.input_buffer.as_str(),
304 showing_command_hints: state.ui.input_buffer.starts_with('/'),
305 theme: &rstate.theme,
306 reasoning_active: state.session.reasoning != ReasoningLevel::None,
307 };
308 let mut input_widget_state = InputState {
309 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
310 };
311 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
312
313 if !state.ui.attachment_focused {
315 let input_area = chunks[3];
316 let content_width = input_area.width.saturating_sub(2) as usize;
317 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
318 &state.ui.input_buffer,
319 state.ui.input_cursor.min(state.ui.input_buffer.len()),
320 content_width,
321 );
322 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
323 }
324
325 let requested = state.session.reasoning;
329 let effective = match supported_reasoning_for(state) {
330 Some(ReasoningCapability::Levels(supp)) => {
331 nearest_effort(requested, &supp).unwrap_or(requested)
332 },
333 _ => requested,
334 };
335 let requested_level = if effective == requested {
336 None
337 } else {
338 Some(requested)
339 };
340
341 if let Some(item) = state.pending_approval.front() {
344 use widgets::ApprovalModalWidget;
345 let options = if item.allowlist_scope.is_empty() {
349 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
350 } else {
351 vec![
352 "1. Yes".to_string(),
353 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
354 "3. No (Esc)".to_string(),
355 ]
356 };
357 let widget = ApprovalModalWidget {
358 theme: &rstate.theme,
359 title: format!("Approval required — {} [{}]", item.tool, item.risk),
360 body: item.prompt.as_str(),
361 options,
362 selected_index: Some(item.selected_option),
363 accent: rstate.theme.colors.warning.to_color(),
364 };
365 frame.render_widget(widget, chunks[4]);
366 } else if let Some(confirm) = &state.confirm {
367 use widgets::ApprovalModalWidget;
368 let widget = ApprovalModalWidget {
369 theme: &rstate.theme,
370 title: "Confirm".to_string(),
371 body: confirm.prompt.as_str(),
372 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
373 selected_index: None,
374 accent: rstate.theme.colors.warning.to_color(),
375 };
376 frame.render_widget(widget, chunks[4]);
377 } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
378 use widgets::ConversationListWidget;
379 let widget = ConversationListWidget {
380 theme: &rstate.theme,
381 candidates,
382 cursor: *cursor,
383 };
384 frame.render_widget(widget, chunks[4]);
385 } else if palette_open {
386 let typed = state
387 .ui
388 .input_buffer
389 .trim_start_matches('/')
390 .split_whitespace()
391 .next()
392 .unwrap_or("");
393 let commands = crate::domain::slash_commands::filter_by_prefix(typed);
394 let palette_widget = SlashPaletteWidget {
395 theme: &rstate.theme,
396 commands,
397 selected_index: state.ui.palette_cursor.unwrap_or(0),
398 };
399 frame.render_widget(palette_widget, chunks[4]);
400 } else {
401 let cwd = state.cwd.display().to_string();
402 let status_widget = StatusWidget {
403 theme: &rstate.theme,
404 working_dir: &cwd,
405 hostname: &rstate.hostname,
406 username: &rstate.username,
407 context_usage: state.session.context_usage.as_ref(),
408 last_usage: state.session.last_token_usage,
409 session_usage: state.session.cumulative_token_usage,
410 model_name: &state.session.model_id,
411 reasoning_level: effective,
412 requested_level,
413 safety_mode: state.session.safety_mode,
414 };
415 frame.render_widget(status_widget, chunks[4]);
416 }
417}
418
419fn build_live_messages<'a>(
423 committed: &'a [crate::models::ChatMessage],
424 turn: &TurnState,
425 now: chrono::DateTime<chrono::Local>,
426) -> std::borrow::Cow<'a, [crate::models::ChatMessage]> {
427 if let TurnState::Generating {
431 partial_text,
432 partial_reasoning,
433 ..
434 } = turn
435 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
436 {
437 let thinking = if partial_reasoning.is_empty() {
438 None
439 } else {
440 Some(partial_reasoning.clone())
441 };
442 let msg = crate::models::ChatMessage {
443 role: crate::models::MessageRole::Assistant,
444 content: partial_text.clone(),
445 timestamp: now,
448 kind: crate::models::ChatMessageKind::Normal,
449 metadata: None,
450 actions: Vec::new(),
451 thinking,
452 images: None,
453 tool_calls: None,
454 tool_call_id: None,
455 tool_name: None,
456 thinking_signature: None,
457 };
458 let mut out = committed.to_vec();
459 out.push(msg);
460 std::borrow::Cow::Owned(out)
461 } else {
462 std::borrow::Cow::Borrowed(committed)
463 }
464}
465
466fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
471 None
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::app::Config;
478 use crate::domain::{State, TurnState};
479 use ratatui::Terminal;
480 use ratatui::backend::TestBackend;
481 use std::path::PathBuf;
482
483 fn mock_state() -> State {
484 State::new(
485 Config::default(),
486 PathBuf::from("/tmp/p"),
487 "ollama/test".to_string(),
488 chrono::Local::now(),
489 )
490 }
491
492 fn render_to_string(state: &State) -> String {
493 let backend = TestBackend::new(80, 24);
494 let mut terminal = Terminal::new(backend).expect("terminal");
495 let mut rstate = RenderCache::new();
496 terminal
497 .draw(|f| render(state, &mut rstate, f))
498 .expect("draw");
499 let buf = terminal.backend().buffer();
500 let mut out = String::new();
501 for y in 0..buf.area.height {
502 for x in 0..buf.area.width {
503 out.push_str(buf[(x, y)].symbol());
504 }
505 out.push('\n');
506 }
507 out
508 }
509
510 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
511 let backend = TestBackend::new(80, 24);
512 let mut terminal = Terminal::new(backend).expect("terminal");
513 let mut rstate = RenderCache::new();
514 terminal
515 .draw(|f| render(state, &mut rstate, f))
516 .expect("draw");
517 terminal.backend().buffer().clone()
518 }
519
520 #[test]
521 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
522 use crate::domain::{GenPhase, TurnId};
523 use crate::models::ChatMessage;
524 use std::borrow::Cow;
525 use std::time::SystemTime;
526
527 let committed = vec![ChatMessage::user("hi")];
528 let now = chrono::Local::now();
529
530 let idle = build_live_messages(&committed, &TurnState::Idle, now);
532 assert!(matches!(idle, Cow::Borrowed(_)));
533 assert_eq!(idle.len(), 1);
534
535 let turn = TurnState::Generating {
538 id: TurnId(1),
539 started: SystemTime::now(),
540 partial_text: "draft".to_string(),
541 partial_reasoning: String::new(),
542 tokens: 0,
543 phase: GenPhase::Sending,
544 thinking_signature: None,
545 pending_tool_calls: Vec::new(),
546 };
547 let live = build_live_messages(&committed, &turn, now);
548 assert!(matches!(live, Cow::Owned(_)));
549 assert_eq!(live.len(), 2);
550 assert_eq!(live[1].timestamp, now);
551 }
552
553 #[test]
554 fn user_prompt_renders_with_highlight_band() {
555 let mut s = mock_state();
556 s.session
557 .append(crate::models::ChatMessage::user("hello there"), s.now);
558 let buf = render_to_buffer(&s);
559 let band_bg = crate::render::theme::Theme::dark()
560 .colors
561 .user_message_background
562 .to_color();
563 let y = (0..buf.area.height)
565 .find(|&y| {
566 (0..buf.area.width)
567 .map(|x| buf[(x, y)].symbol())
568 .collect::<String>()
569 .contains("hello there")
570 })
571 .expect("user prompt should render");
572 let banded = (0..buf.area.width)
575 .filter(|&x| buf[(x, y)].bg == band_bg)
576 .count();
577 assert!(
578 banded >= (buf.area.width as usize) * 3 / 4,
579 "user prompt band should fill most of the row; only {banded}/{} cells banded",
580 buf.area.width
581 );
582 }
583
584 #[test]
585 fn idle_state_renders_cwd_and_model_footer() {
586 let s = mock_state();
587 let frame = render_to_string(&s);
588 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
590 assert!(frame.contains("ollama/test"));
591 }
592
593 #[test]
594 fn status_line_appears_during_generating() {
595 let mut s = mock_state();
596 s.turn = crate::domain::transition::start_generating(
597 crate::domain::TurnId(1),
598 std::time::SystemTime::now(),
599 );
600 let frame = render_to_string(&s);
601 assert!(
602 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
603 "expected generation status in frame"
604 );
605 }
606
607 #[test]
608 fn status_line_names_the_in_flight_tool() {
609 use crate::domain::PendingToolCall;
610 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
611 let mut s = mock_state();
612 let call = PendingToolCall {
613 call_id: crate::domain::ToolCallId(1),
614 source: ModelToolCall {
615 id: Some("c1".to_string()),
616 function: FunctionCall {
617 name: "execute_command".to_string(),
618 arguments: serde_json::json!({"command": "npm run dev"}),
619 },
620 },
621 };
622 s.turn = TurnState::ExecutingTools {
623 id: crate::domain::TurnId(1),
624 started: std::time::SystemTime::now(),
625 calls: vec![call],
626 outcomes: vec![None],
627 };
628 let frame = render_to_string(&s);
629 assert!(frame.contains("Running tools"), "got: {frame}");
630 assert!(
631 frame.contains("npm run dev"),
632 "status line must name the in-flight command; got: {frame}"
633 );
634 }
635
636 #[test]
637 fn status_line_appears_during_tool_execution_and_shows_queue() {
638 let mut s = mock_state();
639 s.turn = TurnState::ExecutingTools {
640 id: crate::domain::TurnId(1),
641 started: std::time::SystemTime::now(),
642 calls: Vec::new(),
643 outcomes: Vec::new(),
644 };
645 s.ui.queued_messages
646 .push_back(crate::domain::QueuedMessage {
647 text: "please steer this".to_string(),
648 attachment_ids: Vec::new(),
649 });
650 let frame = render_to_string(&s);
651 assert!(frame.contains("Running tools"), "expected tool status");
652 assert!(
653 frame.contains("please steer this"),
654 "queued busy input must be visible"
655 );
656 }
657
658 #[test]
659 fn reasoning_blocks_are_collapsed_by_default() {
660 let mut s = mock_state();
661 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
662 first_msg.thinking = Some("first private chain of thought".to_string());
663 s.session.append(first_msg, s.now);
664 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
665 second_msg.thinking = Some("second private chain of thought".to_string());
666 s.session.append(second_msg, s.now);
667 let frame = render_to_string(&s);
668 assert!(!frame.contains("Reasoning hidden"));
670 assert!(frame.contains("first visible answer"));
671 assert!(frame.contains("second visible answer"));
672 assert!(!frame.contains("first private chain of thought"));
673 assert!(!frame.contains("second private chain of thought"));
674 }
675
676 #[test]
680 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
681 let mut s = mock_state();
682 let mut msg = crate::models::ChatMessage::assistant("");
683 msg.thinking = Some("private chain of thought".to_string());
684 msg.actions.push(crate::domain::ActionDisplay {
685 action_type: "Bash".to_string(),
686 target: "dir".to_string(),
687 result: crate::domain::ActionResult::Success {
688 output: "ok".to_string(),
689 images: None,
690 },
691 details: crate::domain::ActionDetails::Simple,
692 duration_seconds: Some(0.015),
693 metadata: None,
694 });
695 s.session.append(msg, s.now);
696 let frame = render_to_string(&s);
697 assert!(
698 !frame.contains("Reasoning hidden"),
699 "no reasoning-hidden placeholder"
700 );
701 assert!(
702 frame.contains("Bash"),
703 "the action still renders even though reasoning is hidden"
704 );
705 }
706
707 #[test]
708 fn committed_message_appears_in_chat_pane() {
709 let mut s = mock_state();
710 s.session.append(
711 crate::models::ChatMessage::user("unique-user-token-xyz"),
712 s.now,
713 );
714 let frame = render_to_string(&s);
715 assert!(frame.contains("unique-user-token-xyz"));
716 }
717
718 #[test]
719 fn palette_renders_when_input_starts_with_slash() {
720 let mut s = mock_state();
721 s.ui.input_buffer = "/help".to_string();
722 s.ui.input_cursor = 5;
723 let frame = render_to_string(&s);
724 assert!(frame.contains("help"));
726 }
727
728 #[test]
729 fn status_line_helper_maps_idle_to_idle() {
730 assert_eq!(
731 GenerationStatus::from_turn(&TurnState::Idle),
732 GenerationStatus::Idle
733 );
734 }
735}