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