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