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