1pub mod markdown;
18pub mod theme;
19pub mod widgets;
20pub mod wrap;
21
22use ratatui::{
23 Frame,
24 layout::{Margin, Rect},
25 style::Style,
26 text::{Line, Span},
27};
28use rustc_hash::FxHashMap;
29
30use mermaid_domain::{State, TurnState};
31use mermaid_model::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
32
33use widgets::{
34 ChatState, ChatWidget, GenerationStatus, InputState, InputWidget, SlashPaletteWidget,
35 StatusWidget, build_status_lines,
36};
37
38pub struct RenderCache {
47 pub chat: ChatState,
48 pub host_shell: mermaid_model::safety::HostShell,
53 pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
57 stitched: Option<StitchedMemo>,
63 pub theme: theme::Theme,
64 applied_theme: Option<(mermaid_domain::ThemeChoice, bool)>,
70 pub home_dir: Option<std::path::PathBuf>,
80 pub version: String,
84 last_mouse_scroll_accum: i32,
88 last_scroll_to_bottom_seq: u32,
91}
92
93struct StitchedMemo {
95 key: u64,
96 messages: Vec<mermaid_model::models::ChatMessage>,
97}
98
99impl RenderCache {
100 #[must_use]
107 pub fn new(home_dir: Option<std::path::PathBuf>) -> Self {
108 Self {
109 chat: ChatState::new(),
110 host_shell: mermaid_model::safety::HostShell::current(),
111 wrapped_line_cache: FxHashMap::default(),
112 theme: theme::Theme::dark(),
113 home_dir,
114 version: env!("CARGO_PKG_VERSION").to_string(),
115 stitched: None,
116 applied_theme: None,
117 last_mouse_scroll_accum: 0,
118 last_scroll_to_bottom_seq: 0,
119 }
120 }
121}
122
123#[expect(
125 clippy::too_many_lines,
126 reason = "the frame composition: every zone is measured before the layout is split (the \
127 status reserve depends on the input height, the tasks band on whether the status zone \
128 rendered), then each is drawn into its chunk; sizing and drawing read the same measurements, \
129 so keeping them in one scope is what keeps the layout arithmetic from scattering"
130)]
131pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
132 let want = (state.ui.theme, state.ui.no_color);
135 if rstate.applied_theme != Some(want) {
136 rstate.theme = if state.ui.no_color {
137 theme::Theme::plain()
138 } else {
139 match state.ui.theme {
140 mermaid_domain::ThemeChoice::Dark => theme::Theme::dark(),
141 mermaid_domain::ThemeChoice::Light => theme::Theme::light(),
142 }
143 };
144 rstate.wrapped_line_cache.clear();
147 rstate.applied_theme = Some(want);
148 }
149
150 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
155 if pending > 0 {
156 rstate.chat.scroll_up(pending as u16);
157 } else if pending < 0 {
158 rstate.chat.scroll_down((-pending) as u16);
159 }
160 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
161 if state.ui.scroll_to_bottom_seq != rstate.last_scroll_to_bottom_seq {
163 rstate.chat.resume_auto_scroll();
164 rstate.last_scroll_to_bottom_seq = state.ui.scroll_to_bottom_seq;
165 }
166
167 let approval_item = state.pending_approval.front();
170 let question_item = if approval_item.is_none() {
171 state.pending_question.front()
172 } else {
173 None
174 };
175 let question_modal_open = question_item.is_some();
180
181 let input_lines = widgets::rendered_row_count(
193 &state.ui.input_buffer,
194 frame.area().width.saturating_sub(4) as usize,
195 )
196 .min(5);
197 let input_height = if question_modal_open {
198 0
199 } else {
200 (input_lines + 2) as u16
201 };
202
203 let status_lines = if question_modal_open {
208 Vec::new()
209 } else if state.is_busy() {
210 let now_sys = std::time::SystemTime::from(state.now);
214 let elapsed_since =
215 |t: std::time::SystemTime| now_sys.duration_since(t).unwrap_or_default();
216 let elapsed = match &state.turn {
217 TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
220 state
221 .runtime
222 .run_started
223 .map_or_else(|| elapsed_since(*started), elapsed_since)
224 },
225 TurnState::Compacting { started, .. } => elapsed_since(*started),
226 TurnState::Cancelling { since, .. } => elapsed_since(*since),
227 TurnState::Idle => std::time::Duration::ZERO,
228 };
229 let (agent_rows, status_override, bg_available) = agent_panel_data(state);
230 let task_headline = state
234 .session
235 .conversation
236 .tasks
237 .active()
238 .map(|t| t.active_form.clone());
239 let committed = state.runtime.run_tokens;
249 let live_child_tokens: usize = state.ui.live_tool_status.values().map(|l| l.tokens).sum();
250 let tokens_display = match &state.turn {
251 TurnState::Generating { tokens, .. } => Some(committed.output_tokens + *tokens),
252 TurnState::ExecutingTools { .. } => Some(committed.output_tokens + live_child_tokens),
253 TurnState::Compacting { .. } | TurnState::Cancelling { .. } | TurnState::Idle => None,
254 };
255 build_status_lines(
256 GenerationStatus::from_turn(&state.turn),
257 elapsed,
258 tokens_display,
259 status_override.as_deref(),
260 &agent_rows,
261 bg_available,
262 task_headline.as_deref(),
263 &state.ui.queued_messages,
264 exit_armed(state),
265 &rstate.theme,
266 frame.area().width.saturating_sub(2),
268 )
269 } else if !state.runtime.background_agents.is_empty() {
270 let (agent_rows, _, _) = agent_panel_data(state);
273 build_status_lines(
274 GenerationStatus::Idle,
275 std::time::Duration::ZERO,
276 None,
277 None,
278 &agent_rows,
279 false,
280 None,
281 &state.ui.queued_messages,
282 exit_armed(state),
283 &rstate.theme,
284 frame.area().width.saturating_sub(2),
285 )
286 } else {
287 Vec::new()
288 };
289
290 let status_reserve = 10 + input_height + 1;
294 let status_line_height = (status_lines.len() as u16)
295 .min(14)
296 .min(frame.area().height.saturating_sub(status_reserve));
297
298 let tasks_store = &state.session.conversation.tasks;
303 let tasks_attached = status_line_height > 0;
304 let tasks_zone_height = if question_modal_open {
305 0
306 } else if widgets::tasks_visible(
307 tasks_store,
308 &state.turn,
309 state.ui.tasks_collapsed,
310 tasks_attached,
311 ) {
312 let tasks_area_width = frame.area().width.saturating_sub(2);
313 widgets::tasks_height(
314 tasks_store,
315 state.ui.tasks_collapsed,
316 tasks_attached,
317 tasks_area_width,
318 &rstate.theme,
319 )
320 .min(
321 frame
322 .area()
323 .height
324 .saturating_sub(status_reserve + status_line_height),
325 )
326 } else {
327 0
328 };
329
330 let pane = bottom_pane(state);
338 let bottom_height = match &pane {
339 BottomPane::Approval => {
340 let body_lines = state
342 .pending_approval
343 .front()
344 .map_or(1, |item| item.prompt.lines().count())
345 .clamp(1, 6) as u16;
346 2 + body_lines + 1 + 3
347 },
348 BottomPane::Question => state.pending_question.front().map_or(2, |qset| {
349 widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
350 }),
351 BottomPane::Confirm => 6,
352 BottomPane::ConversationList | BottomPane::Rewind => 12,
353 BottomPane::PlanConfig => widgets::PLAN_CONFIG_HEIGHT,
354 BottomPane::ModelPicker => widgets::MODEL_PICKER_HEIGHT,
355 BottomPane::FilePicker => {
356 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
357 (rows as u16) + 2
358 },
359 BottomPane::Palette(entries) => (entries.len().clamp(1, 8) as u16) + 2,
360 BottomPane::Status => 1,
361 };
362
363 use ratatui::layout::{Constraint, Direction, Layout};
377 let chunks = Layout::default()
378 .direction(Direction::Vertical)
379 .constraints([
380 Constraint::Fill(1),
381 Constraint::Length(status_line_height),
382 Constraint::Length(tasks_zone_height),
383 Constraint::Length(input_height),
384 Constraint::Length(bottom_height),
385 ])
386 .split(frame.area());
387
388 let chat_area = chunks[0].inner(Margin {
390 horizontal: 1,
391 vertical: 0,
392 });
393 let chat_area = render_session_header(frame, chat_area, state, rstate);
396 let toast = active_toast(state);
401 let (chat_area, toast_area) = match toast {
402 Some(_) if chat_area.height > 1 => (
403 Rect {
404 height: chat_area.height - 1,
405 ..chat_area
406 },
407 Some(Rect {
408 y: chat_area.y + chat_area.height - 1,
409 height: 1,
410 ..chat_area
411 }),
412 ),
413 _ => (chat_area, None),
414 };
415 let committed = state.session.messages();
420 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
421 let key = stitch_fingerprint(committed);
422 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
423 rstate.stitched = Some(StitchedMemo {
424 key,
425 messages: stitch_committed(committed),
426 });
427 }
428 &rstate
429 .stitched
430 .as_ref()
431 .expect("stitched memo populated above")
432 .messages
433 } else {
434 committed
435 };
436 let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
437 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
440 let chat_widget = ChatWidget {
441 messages: live_messages.as_ref(),
442 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
443 theme: &rstate.theme,
444 wrapped_line_cache: &mut rstate.wrapped_line_cache,
445 show_reasoning: state.ui.show_reasoning,
446 blink_on,
447 };
448 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
449
450 if let (Some(text), Some(area)) = (toast, toast_area) {
453 frame.render_widget(
454 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
455 text,
456 Style::new().fg(rstate.theme.colors.info.to_color()),
457 )))
458 .alignment(ratatui::layout::Alignment::Right),
459 area,
460 );
461 }
462
463 if !status_lines.is_empty() {
466 let status_area = chunks[1].inner(Margin {
467 horizontal: 1,
468 vertical: 0,
469 });
470 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
471 }
472
473 if tasks_zone_height > 0 {
475 let tasks_area = chunks[2].inner(Margin {
476 horizontal: 1,
477 vertical: 0,
478 });
479 let lines = widgets::build_task_lines(
480 tasks_store,
481 state.ui.tasks_collapsed,
482 tasks_attached,
483 tasks_area.width,
484 &rstate.theme,
485 );
486 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
487 }
488
489 if !question_modal_open {
493 let input_widget = InputWidget {
494 input: state.ui.input_buffer.as_str(),
495 showing_command_hints: state.ui.input_buffer.starts_with('/'),
496 theme: &rstate.theme,
497 reasoning_active: state.session.reasoning != ReasoningLevel::None,
498 exit_armed: exit_armed(state),
499 rewind_armed: rewind_armed(state),
500 };
501 let mut input_widget_state = InputState {
502 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
503 };
504 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
505
506 let input_area = chunks[3];
508 let content_width = input_area.width.saturating_sub(4) as usize;
509 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
510 &state.ui.input_buffer,
511 state.ui.input_cursor.min(state.ui.input_buffer.len()),
512 content_width,
513 );
514 frame.set_cursor_position((input_area.x + cursor_col + 4, input_area.y + 1 + cursor_row));
516 }
517
518 let requested = state.session.reasoning;
522 let effective = match supported_reasoning_for(state) {
523 Some(ReasoningCapability::Levels(supp)) => {
524 nearest_effort(requested, &supp).unwrap_or(requested)
525 },
526 _ => requested,
527 };
528 let requested_level = if effective == requested {
529 None
530 } else {
531 Some(requested)
532 };
533
534 match pane {
536 BottomPane::Approval => {
537 if let Some(item) = state.pending_approval.front() {
538 use widgets::ApprovalModalWidget;
539 let options = if item.allowlist_scope.is_empty() {
544 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
545 } else {
546 vec![
547 "1. Yes".to_string(),
548 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
549 "3. No (Esc)".to_string(),
550 ]
551 };
552 let widget = ApprovalModalWidget {
553 theme: &rstate.theme,
554 title: format!("Approval required — {} [{}]", item.tool, item.risk),
555 body: item.prompt.as_str(),
556 options,
557 selected_index: Some(item.selected_option),
558 accent: rstate.theme.colors.warning.to_color(),
559 };
560 frame.render_widget(widget, chunks[4]);
561 }
562 },
563 BottomPane::Question => {
564 if let Some(qset) = state.pending_question.front() {
565 use widgets::QuestionModalWidget;
566 let widget = QuestionModalWidget {
567 theme: &rstate.theme,
568 set: qset,
569 width: chunks[4].width,
570 };
571 frame.render_widget(widget, chunks[4]);
572 }
573 },
574 BottomPane::Confirm => {
575 if let Some(confirm) = &state.confirm {
576 use widgets::ApprovalModalWidget;
577 let widget = ApprovalModalWidget {
578 theme: &rstate.theme,
579 title: "Confirm".to_string(),
580 body: confirm.prompt.as_str(),
581 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
582 selected_index: None,
583 accent: rstate.theme.colors.warning.to_color(),
584 };
585 frame.render_widget(widget, chunks[4]);
586 }
587 },
588 BottomPane::ModelPicker => {
589 if let mermaid_domain::UiMode::ModelPicker {
590 candidates,
591 query,
592 cursor,
593 loading,
594 } = &state.ui.mode
595 {
596 use widgets::ModelPickerWidget;
597 let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
598 let widget = ModelPickerWidget {
599 theme: &rstate.theme,
600 matches: &matches,
601 query,
602 cursor: *cursor,
603 loading: *loading,
604 current: &state.session.model_id,
605 };
606 frame.render_widget(widget, chunks[4]);
607 }
608 },
609 BottomPane::ConversationList => {
610 if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode
611 {
612 use widgets::ConversationListWidget;
613 let widget = ConversationListWidget {
614 theme: &rstate.theme,
615 candidates,
616 cursor: *cursor,
617 };
618 frame.render_widget(widget, chunks[4]);
619 }
620 },
621 BottomPane::Rewind => {
622 if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
623 use widgets::RewindPickerWidget;
624 let widget = RewindPickerWidget {
625 theme: &rstate.theme,
626 candidates,
627 cursor: *cursor,
628 };
629 frame.render_widget(widget, chunks[4]);
630 }
631 },
632 BottomPane::PlanConfig => {
633 if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
634 use widgets::PlanConfigWidget;
635 let widget = PlanConfigWidget {
636 theme: &rstate.theme,
637 plan: &state.settings.plan,
638 session_model: &state.session.model_id,
639 cursor: *cursor,
640 };
641 frame.render_widget(widget, chunks[4]);
642 }
643 },
644 BottomPane::FilePicker => {
645 use widgets::FilePickerWidget;
646 let widget = FilePickerWidget {
647 theme: &rstate.theme,
648 matches: &state.ui.file_picker_matches,
649 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
650 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
651 };
652 frame.render_widget(widget, chunks[4]);
653 },
654 BottomPane::Palette(entries) => {
655 let palette_widget = SlashPaletteWidget {
656 theme: &rstate.theme,
657 entries,
658 selected_index: state.ui.palette_cursor.unwrap_or(0),
659 };
660 frame.render_widget(palette_widget, chunks[4]);
661 },
662 BottomPane::Status => {
663 let status_widget = StatusWidget {
664 theme: &rstate.theme,
665 context_usage: state.session.context_usage.as_ref(),
666 model_name: &state.session.model_id,
667 reasoning_level: effective,
668 requested_level,
669 safety_mode: state.session.safety_mode,
672 };
673 frame.render_widget(status_widget, chunks[4]);
674 },
675 }
676}
677
678fn render_session_header(
681 frame: &mut Frame,
682 chat_area: Rect,
683 state: &State,
684 rstate: &RenderCache,
685) -> Rect {
686 use widgets::{SESSION_HEADER_HEIGHT, build_session_header, session_header_visible};
687 if !session_header_visible(state) || chat_area.height < SESSION_HEADER_HEIGHT + 1 {
688 return chat_area;
689 }
690 let lines = build_session_header(
691 &rstate.version,
692 &state.session.model_id,
693 &state.cwd,
694 rstate.home_dir.as_deref(),
695 chat_area.width as usize,
696 &rstate.theme,
697 );
698 let header_area = Rect {
699 height: SESSION_HEADER_HEIGHT,
700 ..chat_area
701 };
702 frame.render_widget(ratatui::widgets::Paragraph::new(lines), header_area);
703 Rect {
704 y: chat_area.y + SESSION_HEADER_HEIGHT,
705 height: chat_area.height - SESSION_HEADER_HEIGHT,
706 ..chat_area
707 }
708}
709
710enum BottomPane<'a> {
720 Approval,
721 Question,
722 Confirm,
723 ModelPicker,
724 ConversationList,
725 Rewind,
726 PlanConfig,
727 FilePicker,
728 Palette(Vec<mermaid_domain::slash_commands::PaletteEntry<'a>>),
729 Status,
730}
731
732fn bottom_pane(state: &mermaid_domain::State) -> BottomPane<'_> {
733 use mermaid_domain::{Focus, UiMode};
734 match state.focus() {
735 Focus::ApprovalModal => BottomPane::Approval,
736 Focus::QuestionModal => BottomPane::Question,
737 Focus::ConfirmModal => BottomPane::Confirm,
738 Focus::Picker => match state.ui.mode {
739 UiMode::ModelPicker { .. } => BottomPane::ModelPicker,
740 UiMode::ConversationList { .. } => BottomPane::ConversationList,
741 UiMode::RewindPicker { .. } => BottomPane::Rewind,
742 UiMode::PlanConfig { .. } => BottomPane::PlanConfig,
743 UiMode::EditingInput | UiMode::ModelList => BottomPane::Status,
745 },
746 Focus::Composer => {
747 if state.ui.file_picker_open() {
748 BottomPane::FilePicker
749 } else if state.ui.input_buffer.starts_with('/') {
750 let typed = state
751 .ui
752 .input_buffer
753 .trim_start_matches('/')
754 .split_whitespace()
755 .next()
756 .unwrap_or("");
757 BottomPane::Palette(mermaid_domain::slash_commands::filter_entries(
758 typed,
759 &state.plugin_commands,
760 ))
761 } else {
762 BottomPane::Status
763 }
764 },
765 }
766}
767
768pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
775 prev.role == mermaid_model::models::MessageRole::Assistant
776 && matches!(
777 prev.kind,
778 mermaid_model::models::ChatMessageKind::Normal
779 | mermaid_model::models::ChatMessageKind::Continuation
780 )
781 && prev.tool_calls.is_none()
782}
783
784fn chat_content_key(
804 state: &State,
805 base: &[mermaid_model::models::ChatMessage],
806 live: &[mermaid_model::models::ChatMessage],
807 blink_on: bool,
808) -> u64 {
809 use std::hash::{Hash, Hasher};
810 let mut h = rustc_hash::FxHasher::default();
811 state.session.conversation.revision().hash(&mut h);
812 base.len().hash(&mut h);
815 for msg in live.iter().skip(base.len()) {
816 msg.content.hash(&mut h);
817 msg.thinking.hash(&mut h);
818 std::mem::discriminant(&msg.kind).hash(&mut h);
819 msg.actions.len().hash(&mut h);
820 for action in &msg.actions {
821 action.action_type.hash(&mut h);
822 action.target.hash(&mut h);
823 std::mem::discriminant(&action.result).hash(&mut h);
824 }
825 }
826 if !matches!(state.turn, TurnState::Idle) {
827 blink_on.hash(&mut h);
828 }
829 h.finish()
830}
831
832fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
853 let live_continuation = matches!(
854 turn,
855 TurnState::Generating { continuation, .. } if *continuation
856 );
857 live_continuation
858 || committed
859 .iter()
860 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
861}
862
863fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
869 use std::hash::{Hash, Hasher};
870 use std::mem::discriminant;
871
872 let mut h = rustc_hash::FxHasher::default();
873 committed.len().hash(&mut h);
874 for msg in committed {
875 msg.content.hash(&mut h);
876 msg.thinking.hash(&mut h);
877 msg.timestamp.timestamp().hash(&mut h);
878 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
879 msg.image_numbers
880 .as_ref()
881 .map_or(0, |v| v.len())
882 .hash(&mut h);
883 discriminant(&msg.role).hash(&mut h);
884 discriminant(&msg.kind).hash(&mut h);
885 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
886 msg.actions.len().hash(&mut h);
894 for action in &msg.actions {
895 action.action_type.hash(&mut h);
896 action.target.hash(&mut h);
897 discriminant(&action.result).hash(&mut h);
898 discriminant(&action.details).hash(&mut h);
899 action.duration_seconds.map(f64::to_bits).hash(&mut h);
900 if let Some(meta) = &action.metadata {
901 meta.lines_added.hash(&mut h);
902 meta.lines_removed.hash(&mut h);
903 meta.diff_truncated.hash(&mut h);
904 meta.display_diff.as_ref().map(String::len).hash(&mut h);
905 }
906 }
907 }
908 h.finish()
909}
910
911fn stitch_committed(
923 committed: &[mermaid_model::models::ChatMessage],
924) -> Vec<mermaid_model::models::ChatMessage> {
925 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
926 for msg in committed {
927 if matches!(
928 msg.kind,
929 mermaid_model::models::ChatMessageKind::RecoveryNudge
930 | mermaid_model::models::ChatMessageKind::ContextMarker
931 ) {
932 continue;
933 }
934 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
935 && let Some(prev) = out.last_mut()
936 && mergeable_into(prev)
937 {
938 merge_continuation(prev, msg);
939 continue;
940 }
941 out.push(msg.clone());
942 }
943 out
944}
945
946fn merge_continuation(
950 prev: &mut mermaid_model::models::ChatMessage,
951 cont: &mermaid_model::models::ChatMessage,
952) {
953 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
954 prev.content.push_str(&cont.content[skip..]);
955 if let Some(cont_thinking) = &cont.thinking {
956 match &mut prev.thinking {
957 Some(t) => {
958 t.push_str("\n\n");
959 t.push_str(cont_thinking);
960 },
961 None => prev.thinking = Some(cont_thinking.clone()),
962 }
963 }
964 prev.actions.extend(cont.actions.iter().cloned());
965 if let Some(imgs) = &cont.images {
966 prev.images
967 .get_or_insert_with(Vec::new)
968 .extend(imgs.iter().cloned());
969 }
970 if let Some(nums) = &cont.image_numbers {
971 prev.image_numbers
972 .get_or_insert_with(Vec::new)
973 .extend(nums.iter().copied());
974 }
975 if cont.tool_calls.is_some() {
978 prev.tool_calls = cont.tool_calls.clone();
979 }
980}
981
982fn build_live_messages<'a>(
1002 committed: &'a [mermaid_model::models::ChatMessage],
1003 turn: &TurnState,
1004 now: chrono::DateTime<chrono::Local>,
1005 host_shell: mermaid_model::safety::HostShell,
1006) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
1007 if let TurnState::ExecutingTools {
1008 calls, outcomes, ..
1009 } = turn
1010 {
1011 let actions: Vec<mermaid_domain::ActionDisplay> = calls
1012 .iter()
1013 .zip(outcomes)
1014 .filter_map(|(call, outcome)| match outcome {
1015 Some(outcome) => Some(mermaid_domain::action_display::action_display_for_shell(
1016 call, outcome, host_shell,
1017 )),
1018 None => {
1019 let name = call.source.function.name.as_str();
1020 if name == "agent" || name == "ask_user_question" {
1021 return None;
1022 }
1023 let (action_type, target) =
1024 mermaid_domain::display_info_for_shell(call, host_shell);
1025 Some(mermaid_domain::ActionDisplay {
1026 action_type,
1027 target,
1028 result: mermaid_domain::ActionResult::Running,
1029 details: mermaid_domain::ActionDetails::Simple,
1030 duration_seconds: None,
1031 metadata: None,
1032 })
1033 },
1034 })
1035 .collect();
1036 if actions.is_empty() {
1037 return std::borrow::Cow::Borrowed(committed);
1038 }
1039 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1040 msg.timestamp = now;
1041 msg.actions = actions;
1042 let mut out = committed.to_vec();
1043 out.push(msg);
1044 return std::borrow::Cow::Owned(out);
1045 }
1046 if let TurnState::Generating {
1050 partial_text,
1051 partial_reasoning,
1052 continuation,
1053 ..
1054 } = turn
1055 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
1056 {
1057 let thinking = if partial_reasoning.is_empty() {
1058 None
1059 } else {
1060 Some(partial_reasoning.clone())
1061 };
1062 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
1063 let content = if stitching {
1064 let prev = &committed[committed.len() - 1].content;
1065 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
1066 partial_text[skip..].to_string()
1067 } else {
1068 partial_text.clone()
1069 };
1070 let msg = mermaid_model::models::ChatMessage {
1071 role: mermaid_model::models::MessageRole::Assistant,
1072 content,
1073 timestamp: now,
1076 kind: if stitching {
1077 mermaid_model::models::ChatMessageKind::Continuation
1078 } else {
1079 mermaid_model::models::ChatMessageKind::Normal
1080 },
1081 metadata: None,
1082 actions: Vec::new(),
1083 thinking,
1084 images: None,
1085 image_numbers: None,
1086 tool_calls: None,
1087 tool_call_id: None,
1088 tool_name: None,
1089 provider_continuation: None,
1090 };
1091 let mut out = committed.to_vec();
1092 out.push(msg);
1093 std::borrow::Cow::Owned(out)
1094 } else {
1095 std::borrow::Cow::Borrowed(committed)
1096 }
1097}
1098
1099fn exit_armed(state: &State) -> bool {
1103 state
1104 .ui
1105 .exit_armed_until
1106 .is_some_and(|deadline| state.now <= deadline)
1107}
1108
1109fn active_toast(state: &State) -> Option<String> {
1113 state
1114 .ui
1115 .toast
1116 .as_ref()
1117 .filter(|(_, until)| state.now <= *until)
1118 .map(|(text, _)| text.clone())
1119}
1120
1121fn rewind_armed(state: &State) -> bool {
1124 state
1125 .ui
1126 .esc_armed_at
1127 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1128}
1129
1130fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1135 let now_sys = std::time::SystemTime::from(state.now);
1136 let elapsed_since =
1137 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1138
1139 let mut rows = Vec::new();
1140 let mut running_agents = 0usize;
1141 let mut pending_total = 0usize;
1142 let mut bg_available = false;
1143 if let TurnState::ExecutingTools {
1144 calls,
1145 outcomes,
1146 started,
1147 ..
1148 } = &state.turn
1149 {
1150 let elapsed = elapsed_since(*started);
1151 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1152 pending_total += 1;
1153 let name = call.source.function.name.as_str();
1154 if name == "execute_command" || name == "agent" {
1155 bg_available = true;
1156 }
1157 if name != "agent" {
1158 continue;
1159 }
1160 running_agents += 1;
1161 let (_, description) = mermaid_domain::display_info_for(call);
1162 let live = state.ui.live_tool_status.get(&call.call_id);
1163 rows.push(widgets::AgentPanelRow {
1164 description,
1165 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1166 tokens: live.map_or(0, |l| l.tokens),
1167 elapsed_secs: elapsed,
1168 backgrounded: false,
1169 });
1170 }
1171 }
1172 for agent in &state.runtime.background_agents {
1173 rows.push(widgets::AgentPanelRow {
1174 description: agent.description.clone(),
1175 activity: agent.activity.clone(),
1176 tokens: agent.tokens,
1177 elapsed_secs: elapsed_since(agent.started),
1178 backgrounded: true,
1179 });
1180 }
1181 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1182 if running_agents == 1 {
1183 "Running 1 agent".to_string()
1184 } else {
1185 format!("Running {running_agents} agents")
1186 }
1187 });
1188 (rows, status_override, bg_available)
1189}
1190
1191fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1196 None
1197}
1198
1199#[cfg(test)]
1204pub(crate) fn render_frame(
1205 state: &State,
1206 rstate: &mut RenderCache,
1207 width: u16,
1208 height: u16,
1209) -> String {
1210 use ratatui::Terminal;
1211 use ratatui::backend::TestBackend;
1212 let backend = TestBackend::new(width, height);
1213 let mut terminal = Terminal::new(backend).expect("terminal");
1214 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1215 let buf = terminal.backend().buffer();
1216 let mut out = String::new();
1217 for y in 0..buf.area.height {
1218 for x in 0..buf.area.width {
1219 out.push_str(buf[(x, y)].symbol());
1220 }
1221 out.push('\n');
1222 }
1223 out
1224}
1225
1226#[cfg(test)]
1230mod snapshots;
1231
1232#[cfg(test)]
1234mod bench;
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use mermaid_domain::Config;
1240 use mermaid_domain::{State, TurnState};
1241 use ratatui::Terminal;
1242 use ratatui::backend::TestBackend;
1243 use std::path::PathBuf;
1244
1245 fn mock_state() -> State {
1246 State::new(
1247 Config::default(),
1248 PathBuf::from("/tmp/p"),
1249 "ollama/test".to_string(),
1250 chrono::Local::now(),
1251 PathBuf::from("/tmp"),
1252 )
1253 }
1254
1255 fn test_cache() -> RenderCache {
1258 RenderCache::new(None)
1259 }
1260
1261 fn render_to_string(state: &State) -> String {
1262 render_frame(state, &mut test_cache(), 80, 24)
1263 }
1264
1265 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1266 let backend = TestBackend::new(80, 24);
1267 let mut terminal = Terminal::new(backend).expect("terminal");
1268 let mut rstate = test_cache();
1269 terminal
1270 .draw(|f| render(state, &mut rstate, f))
1271 .expect("draw");
1272 terminal.backend().buffer().clone()
1273 }
1274
1275 #[test]
1276 fn theme_choice_changes_colors_never_glyphs() {
1277 let mut state = mock_state();
1281 state
1282 .session
1283 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1284 let dark = render_to_string(&state);
1285 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1286 let light = render_to_string(&state);
1287 assert_eq!(dark, light, "light theme changed glyphs");
1288 state.ui.no_color = true;
1289 let plain = render_to_string(&state);
1290 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1291 }
1292
1293 #[test]
1294 fn theme_memo_swaps_palette_on_state_change() {
1295 let mut state = mock_state();
1296 let mut rstate = test_cache();
1297 render_frame(&state, &mut rstate, 80, 24);
1298 assert_eq!(rstate.theme.name, "Dark");
1299 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1300 render_frame(&state, &mut rstate, 80, 24);
1301 assert_eq!(rstate.theme.name, "Light");
1302 state.ui.no_color = true;
1304 render_frame(&state, &mut rstate, 80, 24);
1305 assert_eq!(rstate.theme.name, "Plain");
1306 }
1307
1308 #[test]
1309 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1310 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1311
1312 let mut state = mock_state();
1313 let call_id = ToolCallId(7);
1314 state.turn = TurnState::ExecutingTools {
1315 id: TurnId(1),
1316 started: std::time::SystemTime::now(),
1317 calls: vec![PendingToolCall {
1318 call_id,
1319 source: mermaid_model::models::tool_call::ToolCall {
1320 id: None,
1321 function: mermaid_model::models::tool_call::FunctionCall {
1322 name: "agent".to_string(),
1323 arguments: serde_json::json!({"description": "explore crates"}),
1324 },
1325 },
1326 }],
1327 outcomes: vec![None],
1328 };
1329 state.ui.live_tool_status.insert(
1330 call_id,
1331 LiveToolStatus {
1332 activity: "read_file…".to_string(),
1333 tokens: 12_300,
1334 },
1335 );
1336
1337 let live = build_live_messages(
1340 &[],
1341 &state.turn,
1342 chrono::Local::now(),
1343 mermaid_model::safety::HostShell::Posix,
1344 );
1345 assert!(
1346 live.is_empty(),
1347 "a pending agent call must not synthesize a transcript row"
1348 );
1349 let (rows, override_text, bg_available) = agent_panel_data(&state);
1350 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1351 assert!(bg_available, "agents are detachable via ctrl+b");
1352 assert_eq!(rows.len(), 1);
1353 assert_eq!(rows[0].description, "explore crates");
1354 assert_eq!(rows[0].activity, "read_file…");
1355 assert_eq!(rows[0].tokens, 12_300);
1356 assert!(!rows[0].backgrounded);
1357 }
1358
1359 #[test]
1360 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1361 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1362
1363 let mut state = mock_state();
1364 let exec_id = ToolCallId(8);
1365 let agent_id = ToolCallId(9);
1366 let call = |id, name: &str, args| PendingToolCall {
1367 call_id: id,
1368 source: mermaid_model::models::tool_call::ToolCall {
1369 id: None,
1370 function: mermaid_model::models::tool_call::FunctionCall {
1371 name: name.to_string(),
1372 arguments: args,
1373 },
1374 },
1375 };
1376 state.turn = TurnState::ExecutingTools {
1377 id: TurnId(1),
1378 started: std::time::SystemTime::now(),
1379 calls: vec![
1380 call(
1381 exec_id,
1382 "execute_command",
1383 serde_json::json!({"command": "cargo test"}),
1384 ),
1385 call(
1386 agent_id,
1387 "agent",
1388 serde_json::json!({"description": "audit docs"}),
1389 ),
1390 ],
1391 outcomes: vec![None, None],
1392 };
1393 state.ui.live_tool_status.insert(
1394 exec_id,
1395 LiveToolStatus {
1396 activity: String::new(),
1397 tokens: 0,
1398 },
1399 );
1400
1401 let live = build_live_messages(
1404 &[],
1405 &state.turn,
1406 chrono::Local::now(),
1407 mermaid_model::safety::HostShell::Posix,
1408 );
1409 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1410 let actions = &live[0].actions;
1411 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1412 assert_eq!(actions[0].action_type, "Bash");
1413 assert_eq!(actions[0].target, "cargo test");
1414 assert!(matches!(
1415 actions[0].result,
1416 mermaid_domain::ActionResult::Running
1417 ));
1418 let (rows, override_text, _) = agent_panel_data(&state);
1419 assert_eq!(override_text, None);
1420 assert_eq!(rows.len(), 1);
1421 }
1422
1423 #[test]
1424 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1425 use mermaid_domain::{GenPhase, TurnId};
1426 use mermaid_model::models::ChatMessage;
1427 use std::borrow::Cow;
1428 use std::time::SystemTime;
1429
1430 let committed = vec![ChatMessage::user("hi")];
1431 let now = chrono::Local::now();
1432
1433 let idle = build_live_messages(
1435 &committed,
1436 &TurnState::Idle,
1437 now,
1438 mermaid_model::safety::HostShell::Posix,
1439 );
1440 assert!(matches!(idle, Cow::Borrowed(_)));
1441 assert_eq!(idle.len(), 1);
1442
1443 let turn = TurnState::Generating {
1446 id: TurnId(1),
1447 started: SystemTime::now(),
1448 partial_text: "draft".to_string(),
1449 partial_reasoning: String::new(),
1450 tokens: 0,
1451 phase: GenPhase::Sending,
1452 provider_continuation: None,
1453 pending_tool_calls: Vec::new(),
1454 continuation: false,
1455 };
1456 let live = build_live_messages(
1457 &committed,
1458 &turn,
1459 now,
1460 mermaid_model::safety::HostShell::Posix,
1461 );
1462 assert!(matches!(live, Cow::Owned(_)));
1463 assert_eq!(live.len(), 2);
1464 assert_eq!(live[1].timestamp, now);
1465 }
1466
1467 fn kinded(
1468 mut msg: mermaid_model::models::ChatMessage,
1469 kind: mermaid_model::models::ChatMessageKind,
1470 ) -> mermaid_model::models::ChatMessage {
1471 msg.kind = kind;
1472 msg
1473 }
1474
1475 #[test]
1476 fn stitch_committed_merges_chain_and_hides_nudges() {
1477 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1478 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1479 part1.thinking = Some("first trace".to_string());
1480 let mut part2 = kinded(
1482 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1483 ChatMessageKind::Continuation,
1484 );
1485 part2.thinking = Some("second trace".to_string());
1486 let committed = vec![
1487 ChatMessage::user("audit the widget"),
1488 part1,
1489 kinded(
1490 ChatMessage::system("resume nudge"),
1491 ChatMessageKind::RecoveryNudge,
1492 ),
1493 part2,
1494 ];
1495
1496 assert!(needs_stitch(&committed, &TurnState::Idle));
1497 let stitched = stitch_committed(&committed);
1498 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1499 assert_eq!(
1500 stitched[1].content,
1501 "The audit found three issues in the resolver, and here is the fix.",
1502 "contents merge with the resume echo trimmed"
1503 );
1504 assert_eq!(
1505 stitched[1].thinking.as_deref(),
1506 Some("first trace\n\nsecond trace"),
1507 "both reasoning segments survive in order"
1508 );
1509 assert!(
1510 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1511 "nudges never render"
1512 );
1513 }
1514
1515 #[test]
1518 fn context_markers_are_hidden_from_the_transcript() {
1519 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1520 let committed = vec![
1521 ChatMessage::user("plan this"),
1522 kinded(
1523 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1524 ChatMessageKind::ContextMarker,
1525 ),
1526 ChatMessage::assistant("Grounding first."),
1527 ];
1528 assert!(
1533 !needs_stitch(&committed, &TurnState::Idle),
1534 "a marker alone must not defeat the zero-copy path",
1535 );
1536 let stitched = stitch_committed(&committed);
1538 assert_eq!(stitched.len(), 2, "user + assistant only");
1539 assert!(
1540 !stitched
1541 .iter()
1542 .any(|m| m.content.contains("Plan mode is now ON")),
1543 "markers never render"
1544 );
1545 }
1546
1547 #[test]
1548 fn stitch_refuses_non_bubble_predecessor() {
1549 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1550 let committed = vec![
1554 kinded(
1555 ChatMessage::assistant("checkpoint summary"),
1556 ChatMessageKind::ContextCheckpoint,
1557 ),
1558 kinded(
1559 ChatMessage::assistant("orphaned continuation"),
1560 ChatMessageKind::Continuation,
1561 ),
1562 ];
1563 let stitched = stitch_committed(&committed);
1564 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1565 assert_eq!(stitched[1].content, "orphaned continuation");
1566 }
1567
1568 #[test]
1569 fn needs_stitch_is_false_for_plain_sessions() {
1570 use mermaid_model::models::ChatMessage;
1571 let committed = vec![
1574 ChatMessage::user("hi"),
1575 ChatMessage::assistant("hello"),
1576 ChatMessage::system("note"),
1577 ];
1578 assert!(!needs_stitch(&committed, &TurnState::Idle));
1579 }
1580
1581 #[test]
1589 fn a_live_continuation_still_forces_the_stitch() {
1590 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1591 let committed = vec![
1592 ChatMessage::user("write it"),
1593 ChatMessage::assistant("first half"),
1594 kinded(
1595 ChatMessage::system("output limit — continuing"),
1596 ChatMessageKind::RecoveryNudge,
1597 ),
1598 ];
1599 let streaming = TurnState::Generating {
1600 id: mermaid_domain::TurnId(1),
1601 started: std::time::SystemTime::UNIX_EPOCH,
1602 partial_text: "first half and the rest".to_string(),
1603 partial_reasoning: String::new(),
1604 tokens: 0,
1605 phase: mermaid_domain::GenPhase::Streaming,
1606 provider_continuation: None,
1607 pending_tool_calls: Vec::new(),
1608 continuation: true,
1609 };
1610 assert!(
1611 needs_stitch(&committed, &streaming),
1612 "a live continuation needs the nudge stripped to find its bubble",
1613 );
1614 let stitched = stitch_committed(&committed);
1616 assert!(
1617 stitched.last().is_some_and(mergeable_into),
1618 "the stitched tail is the assistant bubble the partial merges into",
1619 );
1620 }
1621
1622 #[test]
1623 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1624 use mermaid_domain::{GenPhase, TurnId};
1625 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1626
1627 let committed = vec![ChatMessage::assistant(
1628 "the fix lands in the resolver module",
1629 )];
1630 let turn = TurnState::Generating {
1631 id: TurnId(2),
1632 started: std::time::SystemTime::now(),
1633 partial_text: "in the resolver module, specifically the clamp".to_string(),
1634 partial_reasoning: String::new(),
1635 tokens: 0,
1636 phase: GenPhase::Streaming,
1637 provider_continuation: None,
1638 pending_tool_calls: Vec::new(),
1639 continuation: true,
1640 };
1641 let live = build_live_messages(
1642 &committed,
1643 &turn,
1644 chrono::Local::now(),
1645 mermaid_model::safety::HostShell::Posix,
1646 );
1647 let streamed = live.last().expect("pseudo-message appended");
1648 assert_eq!(
1649 streamed.kind,
1650 ChatMessageKind::Continuation,
1651 "the live half is stamped so the widget draws it prefix-less"
1652 );
1653 assert_eq!(
1654 streamed.content, ", specifically the clamp",
1655 "the leading resume echo is trimmed against the committed tail"
1656 );
1657 }
1658
1659 #[test]
1660 fn auto_continued_reply_renders_as_one_bubble() {
1661 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1662 let mut s = mock_state();
1663 s.session.append(ChatMessage::user("audit"), s.now);
1664 s.session
1665 .append(ChatMessage::assistant("part one of the reply"), s.now);
1666 s.session.append(
1667 kinded(
1668 ChatMessage::system("output limit — continuing"),
1669 ChatMessageKind::RecoveryNudge,
1670 ),
1671 s.now,
1672 );
1673 s.session.append(
1674 kinded(
1675 ChatMessage::assistant("and part two lands here"),
1676 ChatMessageKind::Continuation,
1677 ),
1678 s.now,
1679 );
1680
1681 let out = render_to_string(&s);
1682 assert!(out.contains("part one of the reply"));
1683 assert!(out.contains("and part two lands here"));
1684 assert!(
1685 !out.contains("continuing"),
1686 "the recovery nudge never renders"
1687 );
1688 assert_eq!(
1689 out.matches('●').count(),
1690 1,
1691 "both halves share one assistant bullet:\n{out}"
1692 );
1693 }
1694
1695 #[test]
1696 fn streaming_continuation_renders_without_fresh_bullet() {
1697 use mermaid_domain::{GenPhase, TurnId};
1698 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1699 let mut s = mock_state();
1700 s.session.append(ChatMessage::user("audit"), s.now);
1701 s.session
1702 .append(ChatMessage::assistant("part one of the reply"), s.now);
1703 s.session.append(
1704 kinded(
1705 ChatMessage::system("output limit — continuing"),
1706 ChatMessageKind::RecoveryNudge,
1707 ),
1708 s.now,
1709 );
1710 s.turn = TurnState::Generating {
1711 id: TurnId(3),
1712 started: std::time::SystemTime::now(),
1713 partial_text: "and part two streams in".to_string(),
1714 partial_reasoning: String::new(),
1715 tokens: 0,
1716 phase: GenPhase::Streaming,
1717 provider_continuation: None,
1718 pending_tool_calls: Vec::new(),
1719 continuation: true,
1720 };
1721
1722 let out = render_to_string(&s);
1723 assert!(out.contains("part one of the reply"));
1724 assert!(out.contains("and part two streams in"));
1725 assert!(!out.contains("continuing"), "live nudge hidden too");
1726 assert_eq!(
1727 out.matches('●').count(),
1728 1,
1729 "the streaming half joins the committed bubble:\n{out}"
1730 );
1731 }
1732
1733 #[test]
1734 fn user_prompt_renders_with_highlight_band() {
1735 let mut s = mock_state();
1736 s.session.append(
1737 mermaid_model::models::ChatMessage::user("hello there"),
1738 s.now,
1739 );
1740 let buf = render_to_buffer(&s);
1741 let band_bg = crate::render::theme::Theme::dark()
1742 .colors
1743 .user_message_background
1744 .to_color();
1745 let y = (0..buf.area.height)
1747 .find(|&y| {
1748 (0..buf.area.width)
1749 .map(|x| buf[(x, y)].symbol())
1750 .collect::<String>()
1751 .contains("hello there")
1752 })
1753 .expect("user prompt should render");
1754 let banded = (0..buf.area.width)
1757 .filter(|&x| buf[(x, y)].bg == band_bg)
1758 .count();
1759 assert!(
1760 banded >= (buf.area.width as usize) * 3 / 4,
1761 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1762 buf.area.width
1763 );
1764 }
1765
1766 #[test]
1767 fn idle_state_renders_cwd_and_model_footer() {
1768 let s = mock_state();
1769 let frame = render_to_string(&s);
1770 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1772 assert!(frame.contains("ollama/test"));
1773 }
1774
1775 #[test]
1776 fn status_line_appears_during_generating() {
1777 let mut s = mock_state();
1778 s.turn = mermaid_domain::transition::start_generating(
1779 mermaid_domain::TurnId(1),
1780 std::time::SystemTime::now(),
1781 );
1782 let frame = render_to_string(&s);
1783 assert!(
1784 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1785 "expected generation status in frame"
1786 );
1787 }
1788
1789 #[test]
1790 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1791 use mermaid_domain::PendingToolCall;
1792 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1793 let mut s = mock_state();
1794 let call = PendingToolCall {
1795 call_id: mermaid_domain::ToolCallId(1),
1796 source: ModelToolCall {
1797 id: Some("c1".to_string()),
1798 function: FunctionCall {
1799 name: "execute_command".to_string(),
1800 arguments: serde_json::json!({"command": "npm run dev"}),
1801 },
1802 },
1803 };
1804 s.turn = TurnState::ExecutingTools {
1805 id: mermaid_domain::TurnId(1),
1806 started: std::time::SystemTime::now(),
1807 calls: vec![call],
1808 outcomes: vec![None],
1809 };
1810 let frame = render_to_string(&s);
1811 assert!(frame.contains("Running tools..."), "got: {frame}");
1814 assert!(
1815 !frame.contains("Running tools:"),
1816 "status line must not carry tool detail; got: {frame}"
1817 );
1818 assert!(
1820 frame.contains("npm run dev"),
1821 "transcript must show the in-flight call's action row; got: {frame}"
1822 );
1823 }
1824
1825 #[test]
1826 fn pending_question_and_agent_calls_get_no_transcript_row() {
1827 use mermaid_domain::PendingToolCall;
1828 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1829 let mut s = mock_state();
1830 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1831 call_id: mermaid_domain::ToolCallId(id),
1832 source: ModelToolCall {
1833 id: Some(format!("c{id}")),
1834 function: FunctionCall {
1835 name: name.to_string(),
1836 arguments: args,
1837 },
1838 },
1839 };
1840 s.turn = TurnState::ExecutingTools {
1841 id: mermaid_domain::TurnId(1),
1842 started: std::time::SystemTime::now(),
1843 calls: vec![
1844 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1845 mk(
1846 2,
1847 "agent",
1848 serde_json::json!({"description": "scan the repo"}),
1849 ),
1850 ],
1851 outcomes: vec![None, None],
1852 };
1853 let frame = render_to_string(&s);
1854 assert!(
1857 !frame.contains("ask_user_question"),
1858 "pending question must not surface as a transcript row or status text; got: {frame}"
1859 );
1860 }
1861
1862 #[test]
1863 fn status_line_appears_during_tool_execution_and_shows_queue() {
1864 let mut s = mock_state();
1865 s.turn = TurnState::ExecutingTools {
1866 id: mermaid_domain::TurnId(1),
1867 started: std::time::SystemTime::now(),
1868 calls: Vec::new(),
1869 outcomes: Vec::new(),
1870 };
1871 s.ui.queued_messages
1872 .push_back(mermaid_domain::QueuedMessage {
1873 text: "please steer this".to_string(),
1874 attachment_ids: Vec::new(),
1875 });
1876 let frame = render_to_string(&s);
1877 assert!(frame.contains("Running tools"), "expected tool status");
1878 assert!(
1879 frame.contains("please steer this"),
1880 "queued busy input must be visible"
1881 );
1882 }
1883
1884 #[test]
1885 fn reasoning_blocks_are_collapsed_by_default() {
1886 let mut s = mock_state();
1887 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1888 first_msg.thinking = Some("first private chain of thought".to_string());
1889 s.session.append(first_msg, s.now);
1890 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1891 second_msg.thinking = Some("second private chain of thought".to_string());
1892 s.session.append(second_msg, s.now);
1893 let frame = render_to_string(&s);
1894 assert!(!frame.contains("Reasoning hidden"));
1896 assert!(frame.contains("first visible answer"));
1897 assert!(frame.contains("second visible answer"));
1898 assert!(!frame.contains("first private chain of thought"));
1899 assert!(!frame.contains("second private chain of thought"));
1900 }
1901
1902 #[test]
1906 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1907 let mut s = mock_state();
1908 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1909 msg.thinking = Some("private chain of thought".to_string());
1910 msg.actions.push(mermaid_domain::ActionDisplay {
1911 action_type: "Bash".to_string(),
1912 target: "dir".to_string(),
1913 result: mermaid_domain::ActionResult::Success {
1914 output: "ok".to_string(),
1915 images: None,
1916 },
1917 details: mermaid_domain::ActionDetails::Simple,
1918 duration_seconds: Some(0.015),
1919 metadata: None,
1920 });
1921 s.session.append(msg, s.now);
1922 let frame = render_to_string(&s);
1923 assert!(
1924 !frame.contains("Reasoning hidden"),
1925 "no reasoning-hidden placeholder"
1926 );
1927 assert!(
1928 frame.contains("Bash"),
1929 "the action still renders even though reasoning is hidden"
1930 );
1931 }
1932
1933 #[test]
1934 fn committed_message_appears_in_chat_pane() {
1935 let mut s = mock_state();
1936 s.session.append(
1937 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1938 s.now,
1939 );
1940 let frame = render_to_string(&s);
1941 assert!(frame.contains("unique-user-token-xyz"));
1942 }
1943
1944 #[test]
1945 fn palette_renders_when_input_starts_with_slash() {
1946 let mut s = mock_state();
1947 s.ui.input_buffer = "/help".to_string();
1948 s.ui.input_cursor = 5;
1949 let frame = render_to_string(&s);
1950 assert!(frame.contains("help"));
1952 }
1953
1954 #[test]
1955 fn status_line_helper_maps_idle_to_idle() {
1956 assert_eq!(
1957 GenerationStatus::from_turn(&TurnState::Idle),
1958 GenerationStatus::Idle
1959 );
1960 }
1961
1962 #[test]
1974 fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
1975 use ratatui::Terminal;
1976 use ratatui::backend::TestBackend;
1977
1978 let text = "Create a language that. Your goal is up to you.";
1979 for width in [24u16, 30, 40, 55] {
1980 for n in 0..=text.len() {
1981 let Some(prefix) = text.get(..n) else {
1984 continue;
1985 };
1986 let mut state = mock_state();
1987 state.ui.input_buffer = prefix.to_string();
1988 state.ui.input_cursor = prefix.len();
1989
1990 let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
1991 let mut rstate = test_cache();
1992 terminal
1993 .draw(|f| render(&state, &mut rstate, f))
1994 .expect("draw");
1995 let cursor = terminal.get_cursor_position().expect("cursor");
1996 let buf = terminal.backend().buffer().clone();
1997
1998 let row_at = |y: u16| {
1999 (0..buf.area.width)
2000 .map(|x| buf[(x, y)].symbol())
2001 .collect::<String>()
2002 };
2003 let caret_row = row_at(cursor.y);
2004
2005 assert!(
2008 !caret_row.contains('─'),
2009 "caret on the box border at width {width}, prefix {prefix:?}\n\
2010 row {}: |{caret_row}|",
2011 cursor.y
2012 );
2013
2014 let content_width = width.saturating_sub(2) as usize;
2016 let rows = widgets::rendered_row_count(prefix, content_width);
2017 assert!(
2018 rows <= 5,
2019 "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
2020 );
2021 if let Some(last) = prefix.split_whitespace().next_back() {
2022 let frame: String = (0..buf.area.height)
2023 .map(row_at)
2024 .collect::<Vec<_>>()
2025 .join("\n");
2026 assert!(
2027 frame.contains(last),
2028 "last word {last:?} clipped at width {width}, \
2029 prefix {prefix:?}\n{frame}"
2030 );
2031 }
2032 }
2033 }
2034 }
2035}