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, .. } | 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 #[test]
513 fn idle_state_renders_cwd_and_model_footer() {
514 let s = mock_state();
515 let frame = render_to_string(&s);
516 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
518 assert!(frame.contains("ollama/test"));
519 }
520
521 #[test]
522 fn status_line_appears_during_generating() {
523 let mut s = mock_state();
524 s.turn = crate::domain::transition::start_generating(
525 crate::domain::TurnId(1),
526 std::time::SystemTime::now(),
527 );
528 let frame = render_to_string(&s);
529 assert!(
530 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
531 "expected generation status in frame"
532 );
533 }
534
535 #[test]
536 fn status_line_names_the_in_flight_tool() {
537 use crate::domain::PendingToolCall;
538 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
539 let mut s = mock_state();
540 let call = PendingToolCall {
541 call_id: crate::domain::ToolCallId(1),
542 source: ModelToolCall {
543 id: Some("c1".to_string()),
544 function: FunctionCall {
545 name: "execute_command".to_string(),
546 arguments: serde_json::json!({"command": "npm run dev"}),
547 },
548 },
549 };
550 s.turn = TurnState::ExecutingTools {
551 id: crate::domain::TurnId(1),
552 started: std::time::SystemTime::now(),
553 calls: vec![call],
554 outcomes: vec![None],
555 };
556 let frame = render_to_string(&s);
557 assert!(frame.contains("Running tools"), "got: {frame}");
558 assert!(
559 frame.contains("npm run dev"),
560 "status line must name the in-flight command; got: {frame}"
561 );
562 }
563
564 #[test]
565 fn status_line_appears_during_tool_execution_and_shows_queue() {
566 let mut s = mock_state();
567 s.turn = TurnState::ExecutingTools {
568 id: crate::domain::TurnId(1),
569 started: std::time::SystemTime::now(),
570 calls: Vec::new(),
571 outcomes: Vec::new(),
572 };
573 s.ui.queued_messages
574 .push_back(crate::domain::QueuedMessage {
575 text: "please steer this".to_string(),
576 attachment_ids: Vec::new(),
577 });
578 let frame = render_to_string(&s);
579 assert!(frame.contains("Running tools"), "expected tool status");
580 assert!(
581 frame.contains("please steer this"),
582 "queued busy input must be visible"
583 );
584 }
585
586 #[test]
587 fn reasoning_blocks_are_collapsed_by_default() {
588 let mut s = mock_state();
589 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
590 first_msg.thinking = Some("first private chain of thought".to_string());
591 s.session.append(first_msg);
592 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
593 second_msg.thinking = Some("second private chain of thought".to_string());
594 s.session.append(second_msg);
595 let frame = render_to_string(&s);
596 assert_eq!(frame.matches("Reasoning hidden").count(), 1);
597 assert!(frame.contains("first visible answer"));
598 assert!(frame.contains("second visible answer"));
599 assert!(!frame.contains("first private chain of thought"));
600 assert!(!frame.contains("second private chain of thought"));
601 }
602
603 #[test]
609 fn reasoning_placeholder_is_gapped_from_following_action() {
610 let mut s = mock_state();
611 let mut msg = crate::models::ChatMessage::assistant("");
612 msg.thinking = Some("private chain of thought".to_string());
613 msg.actions.push(crate::domain::ActionDisplay {
614 action_type: "Bash".to_string(),
615 target: "dir".to_string(),
616 result: crate::domain::ActionResult::Success {
617 output: "ok".to_string(),
618 images: None,
619 },
620 details: crate::domain::ActionDetails::Simple,
621 duration_seconds: Some(0.015),
622 metadata: None,
623 });
624 s.session.append(msg);
625 let frame = render_to_string(&s);
626 let lines: Vec<&str> = frame.lines().collect();
627 let idx = lines
628 .iter()
629 .position(|l| l.contains("Reasoning hidden"))
630 .expect("placeholder must render");
631 assert!(
632 lines[idx + 1].trim().is_empty(),
633 "a blank line must separate the placeholder from the action; got {:?}",
634 &lines[idx..=(idx + 2).min(lines.len() - 1)]
635 );
636 assert!(
637 lines[idx..].iter().any(|l| l.contains("Bash")),
638 "the action must still render after the placeholder"
639 );
640 }
641
642 #[test]
643 fn scrollbar_shows_when_chat_overflows() {
644 let mut s = mock_state();
645 for i in 0..60 {
646 s.session
647 .append(crate::models::ChatMessage::assistant(format!("msg {i}")));
648 }
649 let frame = render_to_string(&s);
650 assert!(
653 frame.contains('█'),
654 "a scrollbar thumb should render when the transcript overflows the viewport"
655 );
656 }
657
658 #[test]
659 fn committed_message_appears_in_chat_pane() {
660 let mut s = mock_state();
661 s.session
662 .append(crate::models::ChatMessage::user("unique-user-token-xyz"));
663 let frame = render_to_string(&s);
664 assert!(frame.contains("unique-user-token-xyz"));
665 }
666
667 #[test]
668 fn palette_renders_when_input_starts_with_slash() {
669 let mut s = mock_state();
670 s.ui.input_buffer = "/help".to_string();
671 s.ui.input_cursor = 5;
672 let frame = render_to_string(&s);
673 assert!(frame.contains("help"));
675 }
676
677 #[test]
678 fn status_line_helper_maps_idle_to_idle() {
679 assert_eq!(
680 GenerationStatus::from_turn(&TurnState::Idle),
681 GenerationStatus::Idle
682 );
683 }
684
685 #[test]
689 fn state_status_is_never_painted() {
690 let mut s = mock_state();
691 s.status = Some(StatusLine {
692 text: "Reasoning: high".to_string(),
693 kind: StatusKind::Info,
694 shown_at: std::time::SystemTime::now(),
695 });
696 let frame = render_to_string(&s);
697 assert!(
698 !frame.contains("Reasoning: high"),
699 "the status banner is gone; state.status must not reach the screen"
700 );
701 }
702}