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::ModelPicker => widgets::MODEL_PICKER_HEIGHT,
354 BottomPane::FilePicker => {
355 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
356 (rows as u16) + 2
357 },
358 BottomPane::Palette(entries) => (entries.len().clamp(1, 8) as u16) + 2,
359 BottomPane::Status => 1,
360 };
361
362 use ratatui::layout::{Constraint, Direction, Layout};
376 let chunks = Layout::default()
377 .direction(Direction::Vertical)
378 .constraints([
379 Constraint::Fill(1),
380 Constraint::Length(status_line_height),
381 Constraint::Length(tasks_zone_height),
382 Constraint::Length(input_height),
383 Constraint::Length(bottom_height),
384 ])
385 .split(frame.area());
386
387 let chat_area = chunks[0].inner(Margin {
389 horizontal: 1,
390 vertical: 0,
391 });
392 let chat_area = render_session_header(frame, chat_area, state, rstate);
395 let hint = unmatched_command_hint(state);
404 let notice = hint.as_deref().map_or_else(
405 || {
406 active_toast(state).map(|t| {
407 (
408 t,
409 ratatui::layout::Alignment::Right,
410 rstate.theme.colors.info.to_color(),
411 )
412 })
413 },
414 |h| {
415 Some((
416 h.to_string(),
417 ratatui::layout::Alignment::Left,
418 rstate.theme.colors.text_disabled.to_color(),
419 ))
420 },
421 );
422 let (chat_area, notice_area) = match notice {
423 Some(_) if chat_area.height > 1 => (
424 Rect {
425 height: chat_area.height - 1,
426 ..chat_area
427 },
428 Some(Rect {
429 y: chat_area.y + chat_area.height - 1,
430 height: 1,
431 ..chat_area
432 }),
433 ),
434 _ => (chat_area, None),
435 };
436 let committed = state.session.messages();
441 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
442 let key = stitch_fingerprint(committed);
443 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
444 rstate.stitched = Some(StitchedMemo {
445 key,
446 messages: stitch_committed(committed),
447 });
448 }
449 &rstate
450 .stitched
451 .as_ref()
452 .expect("stitched memo populated above")
453 .messages
454 } else {
455 committed
456 };
457 let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
458 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
461 let chat_widget = ChatWidget {
462 messages: live_messages.as_ref(),
463 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
464 theme: &rstate.theme,
465 wrapped_line_cache: &mut rstate.wrapped_line_cache,
466 show_reasoning: state.ui.show_reasoning,
467 blink_on,
468 };
469 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
470
471 if let (Some((text, align, color)), Some(area)) = (notice, notice_area) {
474 frame.render_widget(
475 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
476 text,
477 Style::new().fg(color),
478 )))
479 .alignment(align),
480 area,
481 );
482 }
483
484 if !status_lines.is_empty() {
487 let status_area = chunks[1].inner(Margin {
488 horizontal: 1,
489 vertical: 0,
490 });
491 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
492 }
493
494 if tasks_zone_height > 0 {
496 let tasks_area = chunks[2].inner(Margin {
497 horizontal: 1,
498 vertical: 0,
499 });
500 let lines = widgets::build_task_lines(
501 tasks_store,
502 state.ui.tasks_collapsed,
503 tasks_attached,
504 tasks_area.width,
505 &rstate.theme,
506 );
507 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
508 }
509
510 if !question_modal_open {
514 let input_widget = InputWidget {
515 input: state.ui.input_buffer.as_str(),
516 showing_command_hints: mermaid_domain::input_kind::palette_is_open(
517 &state.ui.input_buffer,
518 &state.plugin_commands,
519 ),
520 theme: &rstate.theme,
521 reasoning_active: state.session.reasoning != ReasoningLevel::None,
522 exit_armed: exit_armed(state),
523 rewind_armed: rewind_armed(state),
524 };
525 let mut input_widget_state = InputState {
526 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
527 };
528 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
529
530 let input_area = chunks[3];
532 let content_width = input_area.width.saturating_sub(4) as usize;
533 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
534 &state.ui.input_buffer,
535 state.ui.input_cursor.min(state.ui.input_buffer.len()),
536 content_width,
537 );
538 frame.set_cursor_position((input_area.x + cursor_col + 4, input_area.y + 1 + cursor_row));
540 }
541
542 let requested = state.session.reasoning;
546 let effective = match supported_reasoning_for(state) {
547 Some(ReasoningCapability::Levels(supp)) => {
548 nearest_effort(requested, &supp).unwrap_or(requested)
549 },
550 _ => requested,
551 };
552 let requested_level = if effective == requested {
553 None
554 } else {
555 Some(requested)
556 };
557
558 match pane {
560 BottomPane::Approval => {
561 if let Some(item) = state.pending_approval.front() {
562 use widgets::ApprovalModalWidget;
563 let options = if item.allowlist_scope.is_empty() {
568 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
569 } else {
570 vec![
571 "1. Yes".to_string(),
572 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
573 "3. No (Esc)".to_string(),
574 ]
575 };
576 let widget = ApprovalModalWidget {
577 theme: &rstate.theme,
578 title: format!("Approval required — {} [{}]", item.tool, item.risk),
579 body: item.prompt.as_str(),
580 options,
581 selected_index: Some(item.selected_option),
582 accent: rstate.theme.colors.warning.to_color(),
583 };
584 frame.render_widget(widget, chunks[4]);
585 }
586 },
587 BottomPane::Question => {
588 if let Some(qset) = state.pending_question.front() {
589 use widgets::QuestionModalWidget;
590 let widget = QuestionModalWidget {
591 theme: &rstate.theme,
592 set: qset,
593 width: chunks[4].width,
594 };
595 frame.render_widget(widget, chunks[4]);
596 }
597 },
598 BottomPane::Confirm => {
599 if let Some(confirm) = &state.confirm {
600 use widgets::ApprovalModalWidget;
601 let widget = ApprovalModalWidget {
602 theme: &rstate.theme,
603 title: "Confirm".to_string(),
604 body: confirm.prompt.as_str(),
605 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
606 selected_index: None,
607 accent: rstate.theme.colors.warning.to_color(),
608 };
609 frame.render_widget(widget, chunks[4]);
610 }
611 },
612 BottomPane::ModelPicker => {
613 if let mermaid_domain::UiMode::ModelPicker {
614 candidates,
615 query,
616 cursor,
617 loading,
618 } = &state.ui.mode
619 {
620 use widgets::ModelPickerWidget;
621 let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
622 let widget = ModelPickerWidget {
623 theme: &rstate.theme,
624 matches: &matches,
625 query,
626 cursor: *cursor,
627 loading: *loading,
628 current: &state.session.model_id,
629 };
630 frame.render_widget(widget, chunks[4]);
631 }
632 },
633 BottomPane::ConversationList => {
634 if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode
635 {
636 use widgets::ConversationListWidget;
637 let widget = ConversationListWidget {
638 theme: &rstate.theme,
639 candidates,
640 cursor: *cursor,
641 };
642 frame.render_widget(widget, chunks[4]);
643 }
644 },
645 BottomPane::Rewind => {
646 if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
647 use widgets::RewindPickerWidget;
648 let widget = RewindPickerWidget {
649 theme: &rstate.theme,
650 candidates,
651 cursor: *cursor,
652 };
653 frame.render_widget(widget, chunks[4]);
654 }
655 },
656 BottomPane::FilePicker => {
657 use widgets::FilePickerWidget;
658 let widget = FilePickerWidget {
659 theme: &rstate.theme,
660 matches: &state.ui.file_picker_matches,
661 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
662 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
663 };
664 frame.render_widget(widget, chunks[4]);
665 },
666 BottomPane::Palette(entries) => {
667 let palette_widget = SlashPaletteWidget {
668 theme: &rstate.theme,
669 entries,
670 selected_index: state.ui.palette_cursor.unwrap_or(0),
671 };
672 frame.render_widget(palette_widget, chunks[4]);
673 },
674 BottomPane::Status => {
675 let status_widget = StatusWidget {
676 theme: &rstate.theme,
677 context_usage: state.session.context_usage.as_ref(),
678 model_name: &state.session.model_id,
679 reasoning_level: effective,
680 requested_level,
681 safety_mode: state.session.safety_mode,
682 };
683 frame.render_widget(status_widget, chunks[4]);
684 },
685 }
686}
687
688fn render_session_header(
691 frame: &mut Frame,
692 chat_area: Rect,
693 state: &State,
694 rstate: &RenderCache,
695) -> Rect {
696 use widgets::{SESSION_HEADER_HEIGHT, build_session_header, session_header_visible};
697 if !session_header_visible(state) || chat_area.height < SESSION_HEADER_HEIGHT + 1 {
698 return chat_area;
699 }
700 let lines = build_session_header(
701 &rstate.version,
702 &state.session.model_id,
703 &state.cwd,
704 rstate.home_dir.as_deref(),
705 chat_area.width as usize,
706 &rstate.theme,
707 );
708 let header_area = Rect {
709 height: SESSION_HEADER_HEIGHT,
710 ..chat_area
711 };
712 frame.render_widget(ratatui::widgets::Paragraph::new(lines), header_area);
713 Rect {
714 y: chat_area.y + SESSION_HEADER_HEIGHT,
715 height: chat_area.height - SESSION_HEADER_HEIGHT,
716 ..chat_area
717 }
718}
719
720enum BottomPane<'a> {
730 Approval,
731 Question,
732 Confirm,
733 ModelPicker,
734 ConversationList,
735 Rewind,
736 FilePicker,
737 Palette(Vec<mermaid_domain::slash_commands::PaletteEntry<'a>>),
738 Status,
739}
740
741fn bottom_pane(state: &mermaid_domain::State) -> BottomPane<'_> {
742 use mermaid_domain::{Focus, UiMode};
743 match state.focus() {
744 Focus::ApprovalModal => BottomPane::Approval,
745 Focus::QuestionModal => BottomPane::Question,
746 Focus::ConfirmModal => BottomPane::Confirm,
747 Focus::Picker => match state.ui.mode {
748 UiMode::ModelPicker { .. } => BottomPane::ModelPicker,
749 UiMode::ConversationList { .. } => BottomPane::ConversationList,
750 UiMode::RewindPicker { .. } => BottomPane::Rewind,
751 UiMode::EditingInput | UiMode::ModelList => BottomPane::Status,
753 },
754 Focus::Composer => {
755 if state.file_picker_open() {
756 BottomPane::FilePicker
757 } else if let Some(rows) = mermaid_domain::input_kind::palette_rows(
758 &state.ui.input_buffer,
759 &state.plugin_commands,
760 ) {
761 BottomPane::Palette(rows)
762 } else {
763 BottomPane::Status
768 }
769 },
770 }
771}
772
773fn unmatched_command_hint(state: &State) -> Option<String> {
782 if mermaid_domain::input_kind::palette_is_open(&state.ui.input_buffer, &state.plugin_commands) {
783 return None;
784 }
785 let word = mermaid_domain::input_kind::unmatched_command_word(&state.ui.input_buffer)?;
786 Some(format!("No commands match \"{word}\""))
787}
788
789pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
796 prev.role == mermaid_model::models::MessageRole::Assistant
797 && matches!(
798 prev.kind,
799 mermaid_model::models::ChatMessageKind::Normal
800 | mermaid_model::models::ChatMessageKind::Continuation
801 )
802 && prev.tool_calls.is_none()
803}
804
805fn chat_content_key(
825 state: &State,
826 base: &[mermaid_model::models::ChatMessage],
827 live: &[mermaid_model::models::ChatMessage],
828 blink_on: bool,
829) -> u64 {
830 use std::hash::{Hash, Hasher};
831 let mut h = rustc_hash::FxHasher::default();
832 state.session.conversation.revision().hash(&mut h);
833 base.len().hash(&mut h);
836 for msg in live.iter().skip(base.len()) {
837 msg.content.hash(&mut h);
838 msg.thinking.hash(&mut h);
839 std::mem::discriminant(&msg.kind).hash(&mut h);
840 msg.actions.len().hash(&mut h);
841 for action in &msg.actions {
842 action.action_type.hash(&mut h);
843 action.target.hash(&mut h);
844 std::mem::discriminant(&action.result).hash(&mut h);
845 }
846 }
847 if !matches!(state.turn, TurnState::Idle) {
848 blink_on.hash(&mut h);
849 }
850 h.finish()
851}
852
853fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
874 let live_continuation = matches!(
875 turn,
876 TurnState::Generating { continuation, .. } if *continuation
877 );
878 live_continuation
879 || committed
880 .iter()
881 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
882}
883
884fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
890 use std::hash::{Hash, Hasher};
891 use std::mem::discriminant;
892
893 let mut h = rustc_hash::FxHasher::default();
894 committed.len().hash(&mut h);
895 for msg in committed {
896 msg.content.hash(&mut h);
897 msg.thinking.hash(&mut h);
898 msg.timestamp.timestamp().hash(&mut h);
899 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
900 msg.image_numbers
901 .as_ref()
902 .map_or(0, |v| v.len())
903 .hash(&mut h);
904 discriminant(&msg.role).hash(&mut h);
905 discriminant(&msg.kind).hash(&mut h);
906 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
907 msg.actions.len().hash(&mut h);
915 for action in &msg.actions {
916 action.action_type.hash(&mut h);
917 action.target.hash(&mut h);
918 discriminant(&action.result).hash(&mut h);
919 discriminant(&action.details).hash(&mut h);
920 action.duration_seconds.map(f64::to_bits).hash(&mut h);
921 if let Some(meta) = &action.metadata {
922 meta.lines_added.hash(&mut h);
923 meta.lines_removed.hash(&mut h);
924 meta.diff_truncated.hash(&mut h);
925 meta.display_diff.as_ref().map(String::len).hash(&mut h);
926 }
927 }
928 }
929 h.finish()
930}
931
932fn stitch_committed(
944 committed: &[mermaid_model::models::ChatMessage],
945) -> Vec<mermaid_model::models::ChatMessage> {
946 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
947 for msg in committed {
948 if matches!(
949 msg.kind,
950 mermaid_model::models::ChatMessageKind::RecoveryNudge
951 | mermaid_model::models::ChatMessageKind::ContextMarker
952 ) {
953 continue;
954 }
955 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
956 && let Some(prev) = out.last_mut()
957 && mergeable_into(prev)
958 {
959 merge_continuation(prev, msg);
960 continue;
961 }
962 out.push(msg.clone());
963 }
964 out
965}
966
967fn merge_continuation(
971 prev: &mut mermaid_model::models::ChatMessage,
972 cont: &mermaid_model::models::ChatMessage,
973) {
974 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
975 prev.content.push_str(&cont.content[skip..]);
976 if let Some(cont_thinking) = &cont.thinking {
977 match &mut prev.thinking {
978 Some(t) => {
979 t.push_str("\n\n");
980 t.push_str(cont_thinking);
981 },
982 None => prev.thinking = Some(cont_thinking.clone()),
983 }
984 }
985 prev.actions.extend(cont.actions.iter().cloned());
986 if let Some(imgs) = &cont.images {
987 prev.images
988 .get_or_insert_with(Vec::new)
989 .extend(imgs.iter().cloned());
990 }
991 if let Some(nums) = &cont.image_numbers {
992 prev.image_numbers
993 .get_or_insert_with(Vec::new)
994 .extend(nums.iter().copied());
995 }
996 if cont.tool_calls.is_some() {
999 prev.tool_calls = cont.tool_calls.clone();
1000 }
1001}
1002
1003fn build_live_messages<'a>(
1023 committed: &'a [mermaid_model::models::ChatMessage],
1024 turn: &TurnState,
1025 now: chrono::DateTime<chrono::Local>,
1026 host_shell: mermaid_model::safety::HostShell,
1027) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
1028 if let TurnState::ExecutingTools {
1029 calls, outcomes, ..
1030 } = turn
1031 {
1032 let actions: Vec<mermaid_domain::ActionDisplay> = calls
1033 .iter()
1034 .zip(outcomes)
1035 .filter_map(|(call, outcome)| match outcome {
1036 Some(outcome) => Some(mermaid_domain::action_display::action_display_for_shell(
1037 call, outcome, host_shell,
1038 )),
1039 None => {
1040 let name = call.source.function.name.as_str();
1041 if name == "agent" || name == "ask_user_question" {
1042 return None;
1043 }
1044 let (action_type, target) =
1045 mermaid_domain::display_info_for_shell(call, host_shell);
1046 Some(mermaid_domain::ActionDisplay {
1047 action_type,
1048 target,
1049 result: mermaid_domain::ActionResult::Running,
1050 details: mermaid_domain::ActionDetails::Simple,
1051 duration_seconds: None,
1052 metadata: None,
1053 })
1054 },
1055 })
1056 .collect();
1057 if actions.is_empty() {
1058 return std::borrow::Cow::Borrowed(committed);
1059 }
1060 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1061 msg.timestamp = now;
1062 msg.actions = actions;
1063 let mut out = committed.to_vec();
1064 out.push(msg);
1065 return std::borrow::Cow::Owned(out);
1066 }
1067 if let TurnState::Generating {
1071 partial_text,
1072 partial_reasoning,
1073 continuation,
1074 ..
1075 } = turn
1076 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
1077 {
1078 let thinking = if partial_reasoning.is_empty() {
1079 None
1080 } else {
1081 Some(partial_reasoning.clone())
1082 };
1083 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
1084 let content = if stitching {
1085 let prev = &committed[committed.len() - 1].content;
1086 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
1087 partial_text[skip..].to_string()
1088 } else {
1089 partial_text.clone()
1090 };
1091 let msg = mermaid_model::models::ChatMessage {
1092 role: mermaid_model::models::MessageRole::Assistant,
1093 content,
1094 timestamp: now,
1097 kind: if stitching {
1098 mermaid_model::models::ChatMessageKind::Continuation
1099 } else {
1100 mermaid_model::models::ChatMessageKind::Normal
1101 },
1102 metadata: None,
1103 actions: Vec::new(),
1104 thinking,
1105 images: None,
1106 image_numbers: None,
1107 tool_calls: None,
1108 tool_call_id: None,
1109 tool_name: None,
1110 provider_continuation: None,
1111 };
1112 let mut out = committed.to_vec();
1113 out.push(msg);
1114 std::borrow::Cow::Owned(out)
1115 } else {
1116 std::borrow::Cow::Borrowed(committed)
1117 }
1118}
1119
1120fn exit_armed(state: &State) -> bool {
1124 state
1125 .ui
1126 .exit_armed_until
1127 .is_some_and(|deadline| state.now <= deadline)
1128}
1129
1130fn active_toast(state: &State) -> Option<String> {
1134 state
1135 .ui
1136 .toast
1137 .as_ref()
1138 .filter(|(_, until)| state.now <= *until)
1139 .map(|(text, _)| text.clone())
1140}
1141
1142fn rewind_armed(state: &State) -> bool {
1145 state
1146 .ui
1147 .esc_armed_at
1148 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1149}
1150
1151fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1156 let now_sys = std::time::SystemTime::from(state.now);
1157 let elapsed_since =
1158 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1159
1160 let mut rows = Vec::new();
1161 let mut running_agents = 0usize;
1162 let mut pending_total = 0usize;
1163 let mut bg_available = false;
1164 if let TurnState::ExecutingTools {
1165 calls,
1166 outcomes,
1167 started,
1168 ..
1169 } = &state.turn
1170 {
1171 let elapsed = elapsed_since(*started);
1172 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1173 pending_total += 1;
1174 let name = call.source.function.name.as_str();
1175 if name == "execute_command" || name == "agent" {
1176 bg_available = true;
1177 }
1178 if name != "agent" {
1179 continue;
1180 }
1181 running_agents += 1;
1182 let (_, description) = mermaid_domain::display_info_for(call);
1183 let live = state.ui.live_tool_status.get(&call.call_id);
1184 rows.push(widgets::AgentPanelRow {
1185 description,
1186 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1187 tokens: live.map_or(0, |l| l.tokens),
1188 elapsed_secs: elapsed,
1189 backgrounded: false,
1190 });
1191 }
1192 }
1193 for agent in &state.runtime.background_agents {
1194 rows.push(widgets::AgentPanelRow {
1195 description: agent.description.clone(),
1196 activity: agent.activity.clone(),
1197 tokens: agent.tokens,
1198 elapsed_secs: elapsed_since(agent.started),
1199 backgrounded: true,
1200 });
1201 }
1202 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1203 if running_agents == 1 {
1204 "Running 1 agent".to_string()
1205 } else {
1206 format!("Running {running_agents} agents")
1207 }
1208 });
1209 (rows, status_override, bg_available)
1210}
1211
1212fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1217 None
1218}
1219
1220#[cfg(test)]
1225pub(crate) fn render_frame(
1226 state: &State,
1227 rstate: &mut RenderCache,
1228 width: u16,
1229 height: u16,
1230) -> String {
1231 use ratatui::Terminal;
1232 use ratatui::backend::TestBackend;
1233 let backend = TestBackend::new(width, height);
1234 let mut terminal = Terminal::new(backend).expect("terminal");
1235 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1236 let buf = terminal.backend().buffer();
1237 let mut out = String::new();
1238 for y in 0..buf.area.height {
1239 for x in 0..buf.area.width {
1240 out.push_str(buf[(x, y)].symbol());
1241 }
1242 out.push('\n');
1243 }
1244 out
1245}
1246
1247#[cfg(test)]
1251mod snapshots;
1252
1253#[cfg(test)]
1255mod bench;
1256
1257#[cfg(test)]
1258mod tests {
1259 use super::*;
1260 use mermaid_domain::Config;
1261 use mermaid_domain::{State, TurnState};
1262 use ratatui::Terminal;
1263 use ratatui::backend::TestBackend;
1264 use std::path::PathBuf;
1265
1266 fn mock_state() -> State {
1267 State::new(
1268 Config::default(),
1269 PathBuf::from("/tmp/p"),
1270 "ollama/test".to_string(),
1271 chrono::Local::now(),
1272 PathBuf::from("/tmp"),
1273 )
1274 }
1275
1276 fn test_cache() -> RenderCache {
1279 RenderCache::new(None)
1280 }
1281
1282 fn render_to_string(state: &State) -> String {
1283 render_frame(state, &mut test_cache(), 80, 24)
1284 }
1285
1286 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1287 let backend = TestBackend::new(80, 24);
1288 let mut terminal = Terminal::new(backend).expect("terminal");
1289 let mut rstate = test_cache();
1290 terminal
1291 .draw(|f| render(state, &mut rstate, f))
1292 .expect("draw");
1293 terminal.backend().buffer().clone()
1294 }
1295
1296 #[test]
1297 fn theme_choice_changes_colors_never_glyphs() {
1298 let mut state = mock_state();
1302 state
1303 .session
1304 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1305 let dark = render_to_string(&state);
1306 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1307 let light = render_to_string(&state);
1308 assert_eq!(dark, light, "light theme changed glyphs");
1309 state.ui.no_color = true;
1310 let plain = render_to_string(&state);
1311 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1312 }
1313
1314 #[test]
1315 fn theme_memo_swaps_palette_on_state_change() {
1316 let mut state = mock_state();
1317 let mut rstate = test_cache();
1318 render_frame(&state, &mut rstate, 80, 24);
1319 assert_eq!(rstate.theme.name, "Dark");
1320 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1321 render_frame(&state, &mut rstate, 80, 24);
1322 assert_eq!(rstate.theme.name, "Light");
1323 state.ui.no_color = true;
1325 render_frame(&state, &mut rstate, 80, 24);
1326 assert_eq!(rstate.theme.name, "Plain");
1327 }
1328
1329 #[test]
1330 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1331 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1332
1333 let mut state = mock_state();
1334 let call_id = ToolCallId(7);
1335 state.turn = TurnState::ExecutingTools {
1336 id: TurnId(1),
1337 started: std::time::SystemTime::now(),
1338 calls: vec![PendingToolCall {
1339 call_id,
1340 source: mermaid_model::models::tool_call::ToolCall {
1341 id: None,
1342 function: mermaid_model::models::tool_call::FunctionCall {
1343 name: "agent".to_string(),
1344 arguments: serde_json::json!({"description": "explore crates"}),
1345 },
1346 },
1347 }],
1348 outcomes: vec![None],
1349 };
1350 state.ui.live_tool_status.insert(
1351 call_id,
1352 LiveToolStatus {
1353 activity: "read_file…".to_string(),
1354 tokens: 12_300,
1355 },
1356 );
1357
1358 let live = build_live_messages(
1361 &[],
1362 &state.turn,
1363 chrono::Local::now(),
1364 mermaid_model::safety::HostShell::Posix,
1365 );
1366 assert!(
1367 live.is_empty(),
1368 "a pending agent call must not synthesize a transcript row"
1369 );
1370 let (rows, override_text, bg_available) = agent_panel_data(&state);
1371 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1372 assert!(bg_available, "agents are detachable via ctrl+b");
1373 assert_eq!(rows.len(), 1);
1374 assert_eq!(rows[0].description, "explore crates");
1375 assert_eq!(rows[0].activity, "read_file…");
1376 assert_eq!(rows[0].tokens, 12_300);
1377 assert!(!rows[0].backgrounded);
1378 }
1379
1380 #[test]
1381 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1382 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1383
1384 let mut state = mock_state();
1385 let exec_id = ToolCallId(8);
1386 let agent_id = ToolCallId(9);
1387 let call = |id, name: &str, args| PendingToolCall {
1388 call_id: id,
1389 source: mermaid_model::models::tool_call::ToolCall {
1390 id: None,
1391 function: mermaid_model::models::tool_call::FunctionCall {
1392 name: name.to_string(),
1393 arguments: args,
1394 },
1395 },
1396 };
1397 state.turn = TurnState::ExecutingTools {
1398 id: TurnId(1),
1399 started: std::time::SystemTime::now(),
1400 calls: vec![
1401 call(
1402 exec_id,
1403 "execute_command",
1404 serde_json::json!({"command": "cargo test"}),
1405 ),
1406 call(
1407 agent_id,
1408 "agent",
1409 serde_json::json!({"description": "audit docs"}),
1410 ),
1411 ],
1412 outcomes: vec![None, None],
1413 };
1414 state.ui.live_tool_status.insert(
1415 exec_id,
1416 LiveToolStatus {
1417 activity: String::new(),
1418 tokens: 0,
1419 },
1420 );
1421
1422 let live = build_live_messages(
1425 &[],
1426 &state.turn,
1427 chrono::Local::now(),
1428 mermaid_model::safety::HostShell::Posix,
1429 );
1430 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1431 let actions = &live[0].actions;
1432 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1433 assert_eq!(actions[0].action_type, "Bash");
1434 assert_eq!(actions[0].target, "cargo test");
1435 assert!(matches!(
1436 actions[0].result,
1437 mermaid_domain::ActionResult::Running
1438 ));
1439 let (rows, override_text, _) = agent_panel_data(&state);
1440 assert_eq!(override_text, None);
1441 assert_eq!(rows.len(), 1);
1442 }
1443
1444 #[test]
1445 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1446 use mermaid_domain::{GenPhase, TurnId};
1447 use mermaid_model::models::ChatMessage;
1448 use std::borrow::Cow;
1449 use std::time::SystemTime;
1450
1451 let committed = vec![ChatMessage::user("hi")];
1452 let now = chrono::Local::now();
1453
1454 let idle = build_live_messages(
1456 &committed,
1457 &TurnState::Idle,
1458 now,
1459 mermaid_model::safety::HostShell::Posix,
1460 );
1461 assert!(matches!(idle, Cow::Borrowed(_)));
1462 assert_eq!(idle.len(), 1);
1463
1464 let turn = TurnState::Generating {
1467 id: TurnId(1),
1468 started: SystemTime::now(),
1469 partial_text: "draft".to_string(),
1470 partial_reasoning: String::new(),
1471 tokens: 0,
1472 phase: GenPhase::Sending,
1473 provider_continuation: None,
1474 pending_tool_calls: Vec::new(),
1475 continuation: false,
1476 };
1477 let live = build_live_messages(
1478 &committed,
1479 &turn,
1480 now,
1481 mermaid_model::safety::HostShell::Posix,
1482 );
1483 assert!(matches!(live, Cow::Owned(_)));
1484 assert_eq!(live.len(), 2);
1485 assert_eq!(live[1].timestamp, now);
1486 }
1487
1488 fn kinded(
1489 mut msg: mermaid_model::models::ChatMessage,
1490 kind: mermaid_model::models::ChatMessageKind,
1491 ) -> mermaid_model::models::ChatMessage {
1492 msg.kind = kind;
1493 msg
1494 }
1495
1496 #[test]
1497 fn stitch_committed_merges_chain_and_hides_nudges() {
1498 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1499 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1500 part1.thinking = Some("first trace".to_string());
1501 let mut part2 = kinded(
1503 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1504 ChatMessageKind::Continuation,
1505 );
1506 part2.thinking = Some("second trace".to_string());
1507 let committed = vec![
1508 ChatMessage::user("audit the widget"),
1509 part1,
1510 kinded(
1511 ChatMessage::system("resume nudge"),
1512 ChatMessageKind::RecoveryNudge,
1513 ),
1514 part2,
1515 ];
1516
1517 assert!(needs_stitch(&committed, &TurnState::Idle));
1518 let stitched = stitch_committed(&committed);
1519 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1520 assert_eq!(
1521 stitched[1].content,
1522 "The audit found three issues in the resolver, and here is the fix.",
1523 "contents merge with the resume echo trimmed"
1524 );
1525 assert_eq!(
1526 stitched[1].thinking.as_deref(),
1527 Some("first trace\n\nsecond trace"),
1528 "both reasoning segments survive in order"
1529 );
1530 assert!(
1531 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1532 "nudges never render"
1533 );
1534 }
1535
1536 #[test]
1539 fn context_markers_are_hidden_from_the_transcript() {
1540 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1541 let committed = vec![
1542 ChatMessage::user("switch to read-only"),
1543 kinded(
1544 ChatMessage::system(
1545 "Safety mode changed from auto to read_only (set by the user).",
1546 ),
1547 ChatMessageKind::ContextMarker,
1548 ),
1549 ChatMessage::assistant("Grounding first."),
1550 ];
1551 assert!(
1556 !needs_stitch(&committed, &TurnState::Idle),
1557 "a marker alone must not defeat the zero-copy path",
1558 );
1559 let stitched = stitch_committed(&committed);
1561 assert_eq!(stitched.len(), 2, "user + assistant only");
1562 assert!(
1563 !stitched
1564 .iter()
1565 .any(|m| m.content.contains("Safety mode changed")),
1566 "markers never render"
1567 );
1568 }
1569
1570 #[test]
1571 fn stitch_refuses_non_bubble_predecessor() {
1572 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1573 let committed = vec![
1577 kinded(
1578 ChatMessage::assistant("checkpoint summary"),
1579 ChatMessageKind::ContextCheckpoint,
1580 ),
1581 kinded(
1582 ChatMessage::assistant("orphaned continuation"),
1583 ChatMessageKind::Continuation,
1584 ),
1585 ];
1586 let stitched = stitch_committed(&committed);
1587 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1588 assert_eq!(stitched[1].content, "orphaned continuation");
1589 }
1590
1591 #[test]
1592 fn needs_stitch_is_false_for_plain_sessions() {
1593 use mermaid_model::models::ChatMessage;
1594 let committed = vec![
1597 ChatMessage::user("hi"),
1598 ChatMessage::assistant("hello"),
1599 ChatMessage::system("note"),
1600 ];
1601 assert!(!needs_stitch(&committed, &TurnState::Idle));
1602 }
1603
1604 #[test]
1612 fn a_live_continuation_still_forces_the_stitch() {
1613 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1614 let committed = vec![
1615 ChatMessage::user("write it"),
1616 ChatMessage::assistant("first half"),
1617 kinded(
1618 ChatMessage::system("output limit — continuing"),
1619 ChatMessageKind::RecoveryNudge,
1620 ),
1621 ];
1622 let streaming = TurnState::Generating {
1623 id: mermaid_domain::TurnId(1),
1624 started: std::time::SystemTime::UNIX_EPOCH,
1625 partial_text: "first half and the rest".to_string(),
1626 partial_reasoning: String::new(),
1627 tokens: 0,
1628 phase: mermaid_domain::GenPhase::Streaming,
1629 provider_continuation: None,
1630 pending_tool_calls: Vec::new(),
1631 continuation: true,
1632 };
1633 assert!(
1634 needs_stitch(&committed, &streaming),
1635 "a live continuation needs the nudge stripped to find its bubble",
1636 );
1637 let stitched = stitch_committed(&committed);
1639 assert!(
1640 stitched.last().is_some_and(mergeable_into),
1641 "the stitched tail is the assistant bubble the partial merges into",
1642 );
1643 }
1644
1645 #[test]
1646 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1647 use mermaid_domain::{GenPhase, TurnId};
1648 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1649
1650 let committed = vec![ChatMessage::assistant(
1651 "the fix lands in the resolver module",
1652 )];
1653 let turn = TurnState::Generating {
1654 id: TurnId(2),
1655 started: std::time::SystemTime::now(),
1656 partial_text: "in the resolver module, specifically the clamp".to_string(),
1657 partial_reasoning: String::new(),
1658 tokens: 0,
1659 phase: GenPhase::Streaming,
1660 provider_continuation: None,
1661 pending_tool_calls: Vec::new(),
1662 continuation: true,
1663 };
1664 let live = build_live_messages(
1665 &committed,
1666 &turn,
1667 chrono::Local::now(),
1668 mermaid_model::safety::HostShell::Posix,
1669 );
1670 let streamed = live.last().expect("pseudo-message appended");
1671 assert_eq!(
1672 streamed.kind,
1673 ChatMessageKind::Continuation,
1674 "the live half is stamped so the widget draws it prefix-less"
1675 );
1676 assert_eq!(
1677 streamed.content, ", specifically the clamp",
1678 "the leading resume echo is trimmed against the committed tail"
1679 );
1680 }
1681
1682 #[test]
1683 fn auto_continued_reply_renders_as_one_bubble() {
1684 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1685 let mut s = mock_state();
1686 s.session.append(ChatMessage::user("audit"), s.now);
1687 s.session
1688 .append(ChatMessage::assistant("part one of the reply"), s.now);
1689 s.session.append(
1690 kinded(
1691 ChatMessage::system("output limit — continuing"),
1692 ChatMessageKind::RecoveryNudge,
1693 ),
1694 s.now,
1695 );
1696 s.session.append(
1697 kinded(
1698 ChatMessage::assistant("and part two lands here"),
1699 ChatMessageKind::Continuation,
1700 ),
1701 s.now,
1702 );
1703
1704 let out = render_to_string(&s);
1705 assert!(out.contains("part one of the reply"));
1706 assert!(out.contains("and part two lands here"));
1707 assert!(
1708 !out.contains("continuing"),
1709 "the recovery nudge never renders"
1710 );
1711 assert_eq!(
1712 out.matches('●').count(),
1713 1,
1714 "both halves share one assistant bullet:\n{out}"
1715 );
1716 }
1717
1718 #[test]
1719 fn streaming_continuation_renders_without_fresh_bullet() {
1720 use mermaid_domain::{GenPhase, TurnId};
1721 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1722 let mut s = mock_state();
1723 s.session.append(ChatMessage::user("audit"), s.now);
1724 s.session
1725 .append(ChatMessage::assistant("part one of the reply"), s.now);
1726 s.session.append(
1727 kinded(
1728 ChatMessage::system("output limit — continuing"),
1729 ChatMessageKind::RecoveryNudge,
1730 ),
1731 s.now,
1732 );
1733 s.turn = TurnState::Generating {
1734 id: TurnId(3),
1735 started: std::time::SystemTime::now(),
1736 partial_text: "and part two streams in".to_string(),
1737 partial_reasoning: String::new(),
1738 tokens: 0,
1739 phase: GenPhase::Streaming,
1740 provider_continuation: None,
1741 pending_tool_calls: Vec::new(),
1742 continuation: true,
1743 };
1744
1745 let out = render_to_string(&s);
1746 assert!(out.contains("part one of the reply"));
1747 assert!(out.contains("and part two streams in"));
1748 assert!(!out.contains("continuing"), "live nudge hidden too");
1749 assert_eq!(
1750 out.matches('●').count(),
1751 1,
1752 "the streaming half joins the committed bubble:\n{out}"
1753 );
1754 }
1755
1756 #[test]
1757 fn user_prompt_renders_with_highlight_band() {
1758 let mut s = mock_state();
1759 s.session.append(
1760 mermaid_model::models::ChatMessage::user("hello there"),
1761 s.now,
1762 );
1763 let buf = render_to_buffer(&s);
1764 let band_bg = crate::render::theme::Theme::dark()
1765 .colors
1766 .user_message_background
1767 .to_color();
1768 let y = (0..buf.area.height)
1770 .find(|&y| {
1771 (0..buf.area.width)
1772 .map(|x| buf[(x, y)].symbol())
1773 .collect::<String>()
1774 .contains("hello there")
1775 })
1776 .expect("user prompt should render");
1777 let banded = (0..buf.area.width)
1780 .filter(|&x| buf[(x, y)].bg == band_bg)
1781 .count();
1782 assert!(
1783 banded >= (buf.area.width as usize) * 3 / 4,
1784 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1785 buf.area.width
1786 );
1787 }
1788
1789 #[test]
1790 fn idle_state_renders_cwd_and_model_footer() {
1791 let s = mock_state();
1792 let frame = render_to_string(&s);
1793 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1795 assert!(frame.contains("ollama/test"));
1796 }
1797
1798 #[test]
1799 fn status_line_appears_during_generating() {
1800 let mut s = mock_state();
1801 s.turn = mermaid_domain::transition::start_generating(
1802 mermaid_domain::TurnId(1),
1803 std::time::SystemTime::now(),
1804 );
1805 let frame = render_to_string(&s);
1806 assert!(
1807 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1808 "expected generation status in frame"
1809 );
1810 }
1811
1812 #[test]
1813 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1814 use mermaid_domain::PendingToolCall;
1815 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1816 let mut s = mock_state();
1817 let call = PendingToolCall {
1818 call_id: mermaid_domain::ToolCallId(1),
1819 source: ModelToolCall {
1820 id: Some("c1".to_string()),
1821 function: FunctionCall {
1822 name: "execute_command".to_string(),
1823 arguments: serde_json::json!({"command": "npm run dev"}),
1824 },
1825 },
1826 };
1827 s.turn = TurnState::ExecutingTools {
1828 id: mermaid_domain::TurnId(1),
1829 started: std::time::SystemTime::now(),
1830 calls: vec![call],
1831 outcomes: vec![None],
1832 };
1833 let frame = render_to_string(&s);
1834 assert!(frame.contains("Running tools..."), "got: {frame}");
1837 assert!(
1838 !frame.contains("Running tools:"),
1839 "status line must not carry tool detail; got: {frame}"
1840 );
1841 assert!(
1843 frame.contains("npm run dev"),
1844 "transcript must show the in-flight call's action row; got: {frame}"
1845 );
1846 }
1847
1848 #[test]
1849 fn pending_question_and_agent_calls_get_no_transcript_row() {
1850 use mermaid_domain::PendingToolCall;
1851 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1852 let mut s = mock_state();
1853 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1854 call_id: mermaid_domain::ToolCallId(id),
1855 source: ModelToolCall {
1856 id: Some(format!("c{id}")),
1857 function: FunctionCall {
1858 name: name.to_string(),
1859 arguments: args,
1860 },
1861 },
1862 };
1863 s.turn = TurnState::ExecutingTools {
1864 id: mermaid_domain::TurnId(1),
1865 started: std::time::SystemTime::now(),
1866 calls: vec![
1867 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1868 mk(
1869 2,
1870 "agent",
1871 serde_json::json!({"description": "scan the repo"}),
1872 ),
1873 ],
1874 outcomes: vec![None, None],
1875 };
1876 let frame = render_to_string(&s);
1877 assert!(
1880 !frame.contains("ask_user_question"),
1881 "pending question must not surface as a transcript row or status text; got: {frame}"
1882 );
1883 }
1884
1885 #[test]
1886 fn status_line_appears_during_tool_execution_and_shows_queue() {
1887 let mut s = mock_state();
1888 s.turn = TurnState::ExecutingTools {
1889 id: mermaid_domain::TurnId(1),
1890 started: std::time::SystemTime::now(),
1891 calls: Vec::new(),
1892 outcomes: Vec::new(),
1893 };
1894 s.ui.queued_messages
1895 .push_back(mermaid_domain::QueuedMessage {
1896 text: "please steer this".to_string(),
1897 attachment_ids: Vec::new(),
1898 });
1899 let frame = render_to_string(&s);
1900 assert!(frame.contains("Running tools"), "expected tool status");
1901 assert!(
1902 frame.contains("please steer this"),
1903 "queued busy input must be visible"
1904 );
1905 }
1906
1907 #[test]
1908 fn reasoning_blocks_are_collapsed_by_default() {
1909 let mut s = mock_state();
1910 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1911 first_msg.thinking = Some("first private chain of thought".to_string());
1912 s.session.append(first_msg, s.now);
1913 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1914 second_msg.thinking = Some("second private chain of thought".to_string());
1915 s.session.append(second_msg, s.now);
1916 let frame = render_to_string(&s);
1917 assert!(!frame.contains("Reasoning hidden"));
1919 assert!(frame.contains("first visible answer"));
1920 assert!(frame.contains("second visible answer"));
1921 assert!(!frame.contains("first private chain of thought"));
1922 assert!(!frame.contains("second private chain of thought"));
1923 }
1924
1925 #[test]
1929 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1930 let mut s = mock_state();
1931 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1932 msg.thinking = Some("private chain of thought".to_string());
1933 msg.actions.push(mermaid_domain::ActionDisplay {
1934 action_type: "Bash".to_string(),
1935 target: "dir".to_string(),
1936 result: mermaid_domain::ActionResult::Success {
1937 output: "ok".to_string(),
1938 images: None,
1939 },
1940 details: mermaid_domain::ActionDetails::Simple,
1941 duration_seconds: Some(0.015),
1942 metadata: None,
1943 });
1944 s.session.append(msg, s.now);
1945 let frame = render_to_string(&s);
1946 assert!(
1947 !frame.contains("Reasoning hidden"),
1948 "no reasoning-hidden placeholder"
1949 );
1950 assert!(
1951 frame.contains("Bash"),
1952 "the action still renders even though reasoning is hidden"
1953 );
1954 }
1955
1956 #[test]
1957 fn committed_message_appears_in_chat_pane() {
1958 let mut s = mock_state();
1959 s.session.append(
1960 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1961 s.now,
1962 );
1963 let frame = render_to_string(&s);
1964 assert!(frame.contains("unique-user-token-xyz"));
1965 }
1966
1967 #[test]
1968 fn palette_renders_when_the_input_names_a_command() {
1969 let mut s = mock_state();
1970 s.ui.input_buffer = "/help".to_string();
1971 s.ui.input_cursor = 5;
1972 let frame = render_to_string(&s);
1973 assert!(frame.contains("help"));
1975 assert!(
1976 frame.contains("Enter Command"),
1977 "and the border cue rides the same authority as the palette"
1978 );
1979 }
1980
1981 fn frame_for(buf: &str) -> String {
1982 let mut s = mock_state();
1983 s.ui.input_buffer = buf.to_string();
1984 s.ui.input_cursor = s.ui.input_buffer.len();
1985 render_to_string(&s)
1986 }
1987
1988 #[test]
1989 fn a_bare_path_gets_a_hint_instead_of_a_palette() {
1990 let frame = frame_for("/home/nsabaj/Downloads/pkg.deb");
1994 assert!(
1995 frame.contains("No commands match"),
1996 "the hint explains it: {frame}"
1997 );
1998 assert!(!frame.contains("Enter Command"));
1999 assert!(!frame.contains("No matching commands"));
2000 }
2001
2002 #[test]
2003 fn a_path_followed_by_prose_gets_no_hint_at_all() {
2004 let frame = frame_for("/home/nsabaj/Downloads/pkg.deb can you make this run on fedora");
2008 assert!(
2009 !frame.contains("No commands match"),
2010 "a sentence needs no caption: {frame}"
2011 );
2012 assert!(!frame.contains("Enter Command"));
2013 assert!(!frame.contains("No matching commands"));
2014 }
2015
2016 #[test]
2017 fn ordinary_prose_gets_neither_palette_nor_hint() {
2018 let frame = frame_for("just a message");
2019 assert!(!frame.contains("No commands match"));
2020 assert!(!frame.contains("Enter Command"));
2021 }
2022
2023 #[test]
2024 fn status_line_helper_maps_idle_to_idle() {
2025 assert_eq!(
2026 GenerationStatus::from_turn(&TurnState::Idle),
2027 GenerationStatus::Idle
2028 );
2029 }
2030
2031 #[test]
2043 fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
2044 use ratatui::Terminal;
2045 use ratatui::backend::TestBackend;
2046
2047 let text = "Create a language that. Your goal is up to you.";
2048 for width in [24u16, 30, 40, 55] {
2049 for n in 0..=text.len() {
2050 let Some(prefix) = text.get(..n) else {
2053 continue;
2054 };
2055 let mut state = mock_state();
2056 state.ui.input_buffer = prefix.to_string();
2057 state.ui.input_cursor = prefix.len();
2058
2059 let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
2060 let mut rstate = test_cache();
2061 terminal
2062 .draw(|f| render(&state, &mut rstate, f))
2063 .expect("draw");
2064 let cursor = terminal.get_cursor_position().expect("cursor");
2065 let buf = terminal.backend().buffer().clone();
2066
2067 let row_at = |y: u16| {
2068 (0..buf.area.width)
2069 .map(|x| buf[(x, y)].symbol())
2070 .collect::<String>()
2071 };
2072 let caret_row = row_at(cursor.y);
2073
2074 assert!(
2077 !caret_row.contains('─'),
2078 "caret on the box border at width {width}, prefix {prefix:?}\n\
2079 row {}: |{caret_row}|",
2080 cursor.y
2081 );
2082
2083 let content_width = width.saturating_sub(2) as usize;
2085 let rows = widgets::rendered_row_count(prefix, content_width);
2086 assert!(
2087 rows <= 5,
2088 "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
2089 );
2090 if let Some(last) = prefix.split_whitespace().next_back() {
2091 let frame: String = (0..buf.area.height)
2092 .map(row_at)
2093 .collect::<Vec<_>>()
2094 .join("\n");
2095 assert!(
2096 frame.contains(last),
2097 "last word {last:?} clipped at width {width}, \
2098 prefix {prefix:?}\n{frame}"
2099 );
2100 }
2101 }
2102 }
2103 }
2104}