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_runtime::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 hostname: String,
77 pub username: String,
78 pub version: String,
82 last_mouse_scroll_accum: i32,
86 last_scroll_to_bottom_seq: u32,
89}
90
91struct StitchedMemo {
93 key: u64,
94 messages: Vec<mermaid_model::models::ChatMessage>,
95}
96
97impl RenderCache {
98 #[must_use]
105 pub fn new(hostname: String, username: String) -> Self {
106 Self {
107 chat: ChatState::new(),
108 host_shell: mermaid_runtime::HostShell::current(),
109 wrapped_line_cache: FxHashMap::default(),
110 theme: theme::Theme::dark(),
111 hostname,
112 username,
113 version: env!("CARGO_PKG_VERSION").to_string(),
114 stitched: None,
115 applied_theme: None,
116 last_mouse_scroll_accum: 0,
117 last_scroll_to_bottom_seq: 0,
118 }
119 }
120}
121
122#[expect(
124 clippy::too_many_lines,
125 reason = "predates the lint; see .github/baselines/expect_budget.txt"
126)]
127pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
128 let want = (state.ui.theme, state.ui.no_color);
131 if rstate.applied_theme != Some(want) {
132 rstate.theme = if state.ui.no_color {
133 theme::Theme::plain()
134 } else {
135 match state.ui.theme {
136 mermaid_domain::ThemeChoice::Dark => theme::Theme::dark(),
137 mermaid_domain::ThemeChoice::Light => theme::Theme::light(),
138 }
139 };
140 rstate.wrapped_line_cache.clear();
143 rstate.applied_theme = Some(want);
144 }
145
146 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
151 if pending > 0 {
152 rstate.chat.scroll_up(pending as u16);
153 } else if pending < 0 {
154 rstate.chat.scroll_down((-pending) as u16);
155 }
156 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
157 if state.ui.scroll_to_bottom_seq != rstate.last_scroll_to_bottom_seq {
159 rstate.chat.resume_auto_scroll();
160 rstate.last_scroll_to_bottom_seq = state.ui.scroll_to_bottom_seq;
161 }
162
163 let approval_item = state.pending_approval.front();
166 let question_item = if approval_item.is_none() {
167 state.pending_question.front()
168 } else {
169 None
170 };
171 let question_modal_open = question_item.is_some();
176
177 let input_lines = widgets::rendered_row_count(
189 &state.ui.input_buffer,
190 frame.area().width.saturating_sub(2) as usize,
191 )
192 .min(5);
193 let input_height = if question_modal_open {
194 0
195 } else {
196 (input_lines + 2) as u16
197 };
198
199 let status_lines = if question_modal_open {
204 Vec::new()
205 } else if state.is_busy() {
206 let now_sys = std::time::SystemTime::from(state.now);
210 let elapsed_since =
211 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
212 let elapsed_secs = match &state.turn {
213 TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
216 state
217 .runtime
218 .run_started
219 .map_or_else(|| elapsed_since(*started), elapsed_since)
220 },
221 TurnState::Compacting { started, .. } => elapsed_since(*started),
222 TurnState::Cancelling { since, .. } => elapsed_since(*since),
223 TurnState::Idle => 0,
224 };
225 let (agent_rows, status_override, bg_available) = agent_panel_data(state);
226 let task_headline = state
230 .session
231 .conversation
232 .tasks
233 .active()
234 .map(|t| t.active_form.clone());
235 let committed = state.runtime.run_tokens;
244 let live_child_tokens: usize = state.ui.live_tool_status.values().map(|l| l.tokens).sum();
245 let (tokens_display, tokens_estimated) = match &state.turn {
246 TurnState::Generating { tokens, .. } => (committed.output_tokens + *tokens, true),
247 TurnState::ExecutingTools { .. } => (
248 committed.output_tokens + live_child_tokens,
249 committed.contains_estimate || live_child_tokens > 0,
250 ),
251 _ => (0, false),
252 };
253 build_status_lines(
254 GenerationStatus::from_turn(&state.turn),
255 elapsed_secs,
256 tokens_display,
257 tokens_estimated,
258 status_override.as_deref(),
259 &agent_rows,
260 bg_available,
261 task_headline.as_deref(),
262 &state.ui.queued_messages,
263 exit_armed(state),
264 &rstate.theme,
265 frame.area().width.saturating_sub(2),
267 )
268 } else if !state.runtime.background_agents.is_empty() {
269 let (agent_rows, _, _) = agent_panel_data(state);
272 build_status_lines(
273 GenerationStatus::Idle,
274 0,
275 0,
276 false,
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 + 2;
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 widgets::tasks_height(tasks_store, state.ui.tasks_collapsed).min(
313 frame
314 .area()
315 .height
316 .saturating_sub(status_reserve + status_line_height),
317 )
318 } else {
319 0
320 };
321
322 let confirm_open =
332 approval_item.is_none() && question_item.is_none() && state.confirm.is_some();
333 let conv_list_open = approval_item.is_none()
334 && question_item.is_none()
335 && !confirm_open
336 && matches!(
337 state.ui.mode,
338 mermaid_domain::UiMode::ConversationList { .. }
339 );
340 let rewind_open = approval_item.is_none()
341 && question_item.is_none()
342 && !confirm_open
343 && matches!(state.ui.mode, mermaid_domain::UiMode::RewindPicker { .. });
344 let plan_config_open = approval_item.is_none()
345 && question_item.is_none()
346 && !confirm_open
347 && matches!(state.ui.mode, mermaid_domain::UiMode::PlanConfig { .. });
348 let model_picker_open = approval_item.is_none()
349 && question_item.is_none()
350 && !confirm_open
351 && matches!(state.ui.mode, mermaid_domain::UiMode::ModelPicker { .. });
352 let file_picker_open = approval_item.is_none()
353 && question_item.is_none()
354 && !confirm_open
355 && !conv_list_open
356 && !rewind_open
357 && !plan_config_open
358 && state.ui.file_picker_open();
359 let palette_open = approval_item.is_none()
360 && question_item.is_none()
361 && !confirm_open
362 && !conv_list_open
363 && !file_picker_open
364 && state.ui.input_buffer.starts_with('/');
365 let bottom_height = if let Some(item) = approval_item {
366 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
368 2 + body_lines + 1 + 3
369 } else if let Some(qset) = question_item {
370 widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
373 } else if confirm_open {
374 6
375 } else if conv_list_open || rewind_open {
376 12
377 } else if plan_config_open {
378 widgets::PLAN_CONFIG_HEIGHT
379 } else if model_picker_open {
380 widgets::MODEL_PICKER_HEIGHT
381 } else if file_picker_open {
382 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
383 (rows as u16) + 2
384 } else if palette_open {
385 let typed = state
386 .ui
387 .input_buffer
388 .trim_start_matches('/')
389 .split_whitespace()
390 .next()
391 .unwrap_or("");
392 let row_count =
393 mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands)
394 .len()
395 .clamp(1, 8);
396 (row_count as u16) + 2
397 } else {
398 2
399 };
400
401 use ratatui::layout::{Constraint, Direction, Layout};
415 let chunks = Layout::default()
416 .direction(Direction::Vertical)
417 .constraints([
418 Constraint::Fill(1),
419 Constraint::Length(status_line_height),
420 Constraint::Length(tasks_zone_height),
421 Constraint::Length(input_height),
422 Constraint::Length(bottom_height),
423 ])
424 .split(frame.area());
425
426 let chat_area = chunks[0].inner(Margin {
428 horizontal: 1,
429 vertical: 0,
430 });
431 let toast = active_toast(state);
436 let (chat_area, toast_area) = match toast {
437 Some(_) if chat_area.height > 1 => (
438 Rect {
439 height: chat_area.height - 1,
440 ..chat_area
441 },
442 Some(Rect {
443 y: chat_area.y + chat_area.height - 1,
444 height: 1,
445 ..chat_area
446 }),
447 ),
448 _ => (chat_area, None),
449 };
450 let committed = state.session.messages();
455 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
456 let key = stitch_fingerprint(committed);
457 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
458 rstate.stitched = Some(StitchedMemo {
459 key,
460 messages: stitch_committed(committed),
461 });
462 }
463 &rstate
464 .stitched
465 .as_ref()
466 .expect("stitched memo populated above")
467 .messages
468 } else {
469 committed
470 };
471 let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
472 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
475 let chat_widget = ChatWidget {
476 messages: live_messages.as_ref(),
477 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
478 theme: &rstate.theme,
479 wrapped_line_cache: &mut rstate.wrapped_line_cache,
480 show_reasoning: state.ui.show_reasoning,
481 blink_on,
482 today: state.now.date_naive(),
483 };
484 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
485
486 if let (Some(text), Some(area)) = (toast, toast_area) {
489 frame.render_widget(
490 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
491 text,
492 Style::new().fg(rstate.theme.colors.info.to_color()),
493 )))
494 .alignment(ratatui::layout::Alignment::Right),
495 area,
496 );
497 }
498
499 if !status_lines.is_empty() {
502 let status_area = chunks[1].inner(Margin {
503 horizontal: 1,
504 vertical: 0,
505 });
506 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
507 }
508
509 if tasks_zone_height > 0 {
511 let tasks_area = chunks[2].inner(Margin {
512 horizontal: 1,
513 vertical: 0,
514 });
515 let lines = widgets::build_task_lines(
516 tasks_store,
517 state.ui.tasks_collapsed,
518 tasks_attached,
519 tasks_area.width,
520 &rstate.theme,
521 );
522 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
523 }
524
525 if !question_modal_open {
529 let input_widget = InputWidget {
530 input: state.ui.input_buffer.as_str(),
531 showing_command_hints: state.ui.input_buffer.starts_with('/'),
532 theme: &rstate.theme,
533 reasoning_active: state.session.reasoning != ReasoningLevel::None,
534 exit_armed: exit_armed(state),
535 rewind_armed: rewind_armed(state),
536 };
537 let mut input_widget_state = InputState {
538 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
539 };
540 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
541
542 let input_area = chunks[3];
544 let content_width = input_area.width.saturating_sub(2) as usize;
545 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
546 &state.ui.input_buffer,
547 state.ui.input_cursor.min(state.ui.input_buffer.len()),
548 content_width,
549 );
550 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
551 }
552
553 let requested = state.session.reasoning;
557 let effective = match supported_reasoning_for(state) {
558 Some(ReasoningCapability::Levels(supp)) => {
559 nearest_effort(requested, &supp).unwrap_or(requested)
560 },
561 _ => requested,
562 };
563 let requested_level = if effective == requested {
564 None
565 } else {
566 Some(requested)
567 };
568
569 if let Some(item) = state.pending_approval.front() {
572 use widgets::ApprovalModalWidget;
573 let options = if item.allowlist_scope.is_empty() {
577 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
578 } else {
579 vec![
580 "1. Yes".to_string(),
581 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
582 "3. No (Esc)".to_string(),
583 ]
584 };
585 let widget = ApprovalModalWidget {
586 theme: &rstate.theme,
587 title: format!("Approval required — {} [{}]", item.tool, item.risk),
588 body: item.prompt.as_str(),
589 options,
590 selected_index: Some(item.selected_option),
591 accent: rstate.theme.colors.warning.to_color(),
592 };
593 frame.render_widget(widget, chunks[4]);
594 } else if let Some(qset) = state.pending_question.front() {
595 use widgets::QuestionModalWidget;
596 let widget = QuestionModalWidget {
597 theme: &rstate.theme,
598 set: qset,
599 width: chunks[4].width,
600 };
601 frame.render_widget(widget, chunks[4]);
602 } else if let Some(confirm) = &state.confirm {
603 use widgets::ApprovalModalWidget;
604 let widget = ApprovalModalWidget {
605 theme: &rstate.theme,
606 title: "Confirm".to_string(),
607 body: confirm.prompt.as_str(),
608 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
609 selected_index: None,
610 accent: rstate.theme.colors.warning.to_color(),
611 };
612 frame.render_widget(widget, chunks[4]);
613 } else 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 } else if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
632 use widgets::ConversationListWidget;
633 let widget = ConversationListWidget {
634 theme: &rstate.theme,
635 candidates,
636 cursor: *cursor,
637 };
638 frame.render_widget(widget, chunks[4]);
639 } else if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
640 use widgets::RewindPickerWidget;
641 let widget = RewindPickerWidget {
642 theme: &rstate.theme,
643 candidates,
644 cursor: *cursor,
645 };
646 frame.render_widget(widget, chunks[4]);
647 } else if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
648 use widgets::PlanConfigWidget;
649 let widget = PlanConfigWidget {
650 theme: &rstate.theme,
651 plan: &state.settings.plan,
652 session_model: &state.session.model_id,
653 cursor: *cursor,
654 };
655 frame.render_widget(widget, chunks[4]);
656 } else if file_picker_open {
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 } else if palette_open {
666 let typed = state
667 .ui
668 .input_buffer
669 .trim_start_matches('/')
670 .split_whitespace()
671 .next()
672 .unwrap_or("");
673 let entries = mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands);
674 let palette_widget = SlashPaletteWidget {
675 theme: &rstate.theme,
676 entries,
677 selected_index: state.ui.palette_cursor.unwrap_or(0),
678 };
679 frame.render_widget(palette_widget, chunks[4]);
680 } else {
681 let cwd = state.cwd.display().to_string();
682 let status_widget = StatusWidget {
683 theme: &rstate.theme,
684 working_dir: &cwd,
685 hostname: &rstate.hostname,
686 username: &rstate.username,
687 version: &rstate.version,
688 context_usage: state.session.context_usage.as_ref(),
689 model_name: &state.session.model_id,
690 reasoning_level: effective,
691 requested_level,
692 safety_mode: state.session.safety_mode,
695 };
696 frame.render_widget(status_widget, chunks[4]);
697 }
698}
699
700pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
707 prev.role == mermaid_model::models::MessageRole::Assistant
708 && matches!(
709 prev.kind,
710 mermaid_model::models::ChatMessageKind::Normal
711 | mermaid_model::models::ChatMessageKind::Continuation
712 )
713 && prev.tool_calls.is_none()
714}
715
716fn chat_content_key(
736 state: &State,
737 base: &[mermaid_model::models::ChatMessage],
738 live: &[mermaid_model::models::ChatMessage],
739 blink_on: bool,
740) -> u64 {
741 use std::hash::{Hash, Hasher};
742 let mut h = rustc_hash::FxHasher::default();
743 state.session.conversation.revision().hash(&mut h);
744 base.len().hash(&mut h);
747 for msg in live.iter().skip(base.len()) {
748 msg.content.hash(&mut h);
749 msg.thinking.hash(&mut h);
750 std::mem::discriminant(&msg.kind).hash(&mut h);
751 msg.actions.len().hash(&mut h);
752 for action in &msg.actions {
753 action.action_type.hash(&mut h);
754 action.target.hash(&mut h);
755 std::mem::discriminant(&action.result).hash(&mut h);
756 }
757 }
758 if !matches!(state.turn, TurnState::Idle) {
759 blink_on.hash(&mut h);
760 }
761 h.finish()
762}
763
764fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
785 let live_continuation = matches!(
786 turn,
787 TurnState::Generating { continuation, .. } if *continuation
788 );
789 live_continuation
790 || committed
791 .iter()
792 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
793}
794
795fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
801 use std::hash::{Hash, Hasher};
802 use std::mem::discriminant;
803
804 let mut h = rustc_hash::FxHasher::default();
805 committed.len().hash(&mut h);
806 for msg in committed {
807 msg.content.hash(&mut h);
808 msg.thinking.hash(&mut h);
809 msg.timestamp.timestamp().hash(&mut h);
810 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
811 msg.image_numbers
812 .as_ref()
813 .map_or(0, |v| v.len())
814 .hash(&mut h);
815 discriminant(&msg.role).hash(&mut h);
816 discriminant(&msg.kind).hash(&mut h);
817 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
818 msg.actions.len().hash(&mut h);
826 for action in &msg.actions {
827 action.action_type.hash(&mut h);
828 action.target.hash(&mut h);
829 discriminant(&action.result).hash(&mut h);
830 discriminant(&action.details).hash(&mut h);
831 action.duration_seconds.map(f64::to_bits).hash(&mut h);
832 if let Some(meta) = &action.metadata {
833 meta.lines_added.hash(&mut h);
834 meta.lines_removed.hash(&mut h);
835 meta.diff_truncated.hash(&mut h);
836 meta.display_diff.as_ref().map(String::len).hash(&mut h);
837 }
838 }
839 }
840 h.finish()
841}
842
843fn stitch_committed(
855 committed: &[mermaid_model::models::ChatMessage],
856) -> Vec<mermaid_model::models::ChatMessage> {
857 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
858 for msg in committed {
859 if matches!(
860 msg.kind,
861 mermaid_model::models::ChatMessageKind::RecoveryNudge
862 | mermaid_model::models::ChatMessageKind::ContextMarker
863 ) {
864 continue;
865 }
866 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
867 && let Some(prev) = out.last_mut()
868 && mergeable_into(prev)
869 {
870 merge_continuation(prev, msg);
871 continue;
872 }
873 out.push(msg.clone());
874 }
875 out
876}
877
878fn merge_continuation(
882 prev: &mut mermaid_model::models::ChatMessage,
883 cont: &mermaid_model::models::ChatMessage,
884) {
885 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
886 prev.content.push_str(&cont.content[skip..]);
887 if let Some(cont_thinking) = &cont.thinking {
888 match &mut prev.thinking {
889 Some(t) => {
890 t.push_str("\n\n");
891 t.push_str(cont_thinking);
892 },
893 None => prev.thinking = Some(cont_thinking.clone()),
894 }
895 }
896 prev.actions.extend(cont.actions.iter().cloned());
897 if let Some(imgs) = &cont.images {
898 prev.images
899 .get_or_insert_with(Vec::new)
900 .extend(imgs.iter().cloned());
901 }
902 if let Some(nums) = &cont.image_numbers {
903 prev.image_numbers
904 .get_or_insert_with(Vec::new)
905 .extend(nums.iter().copied());
906 }
907 if cont.tool_calls.is_some() {
910 prev.tool_calls = cont.tool_calls.clone();
911 }
912}
913
914fn build_live_messages<'a>(
934 committed: &'a [mermaid_model::models::ChatMessage],
935 turn: &TurnState,
936 now: chrono::DateTime<chrono::Local>,
937 host_shell: mermaid_runtime::HostShell,
938) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
939 if let TurnState::ExecutingTools {
940 calls, outcomes, ..
941 } = turn
942 {
943 let actions: Vec<mermaid_domain::ActionDisplay> = calls
944 .iter()
945 .zip(outcomes)
946 .filter_map(|(call, outcome)| match outcome {
947 Some(outcome) => Some(mermaid_domain::transition::action_display_for_shell(
948 call, outcome, host_shell,
949 )),
950 None => {
951 let name = call.source.function.name.as_str();
952 if name == "agent" || name == "ask_user_question" {
953 return None;
954 }
955 let (action_type, target) =
956 mermaid_domain::display_info_for_shell(call, host_shell);
957 Some(mermaid_domain::ActionDisplay {
958 action_type,
959 target,
960 result: mermaid_domain::ActionResult::Running,
961 details: mermaid_domain::ActionDetails::Simple,
962 duration_seconds: None,
963 metadata: None,
964 })
965 },
966 })
967 .collect();
968 if actions.is_empty() {
969 return std::borrow::Cow::Borrowed(committed);
970 }
971 let mut msg = mermaid_model::models::ChatMessage::assistant("");
972 msg.timestamp = now;
973 msg.actions = actions;
974 let mut out = committed.to_vec();
975 out.push(msg);
976 return std::borrow::Cow::Owned(out);
977 }
978 if let TurnState::Generating {
982 partial_text,
983 partial_reasoning,
984 continuation,
985 ..
986 } = turn
987 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
988 {
989 let thinking = if partial_reasoning.is_empty() {
990 None
991 } else {
992 Some(partial_reasoning.clone())
993 };
994 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
995 let content = if stitching {
996 let prev = &committed[committed.len() - 1].content;
997 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
998 partial_text[skip..].to_string()
999 } else {
1000 partial_text.clone()
1001 };
1002 let msg = mermaid_model::models::ChatMessage {
1003 role: mermaid_model::models::MessageRole::Assistant,
1004 content,
1005 timestamp: now,
1008 kind: if stitching {
1009 mermaid_model::models::ChatMessageKind::Continuation
1010 } else {
1011 mermaid_model::models::ChatMessageKind::Normal
1012 },
1013 metadata: None,
1014 actions: Vec::new(),
1015 thinking,
1016 images: None,
1017 image_numbers: None,
1018 tool_calls: None,
1019 tool_call_id: None,
1020 tool_name: None,
1021 provider_continuation: None,
1022 };
1023 let mut out = committed.to_vec();
1024 out.push(msg);
1025 std::borrow::Cow::Owned(out)
1026 } else {
1027 std::borrow::Cow::Borrowed(committed)
1028 }
1029}
1030
1031fn exit_armed(state: &State) -> bool {
1035 state
1036 .ui
1037 .exit_armed_until
1038 .is_some_and(|deadline| state.now <= deadline)
1039}
1040
1041fn active_toast(state: &State) -> Option<String> {
1045 state
1046 .ui
1047 .toast
1048 .as_ref()
1049 .filter(|(_, until)| state.now <= *until)
1050 .map(|(text, _)| text.clone())
1051}
1052
1053fn rewind_armed(state: &State) -> bool {
1056 state
1057 .ui
1058 .esc_armed_at
1059 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1060}
1061
1062fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1067 let now_sys = std::time::SystemTime::from(state.now);
1068 let elapsed_since =
1069 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1070
1071 let mut rows = Vec::new();
1072 let mut running_agents = 0usize;
1073 let mut pending_total = 0usize;
1074 let mut bg_available = false;
1075 if let TurnState::ExecutingTools {
1076 calls,
1077 outcomes,
1078 started,
1079 ..
1080 } = &state.turn
1081 {
1082 let elapsed = elapsed_since(*started);
1083 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1084 pending_total += 1;
1085 let name = call.source.function.name.as_str();
1086 if name == "execute_command" || name == "agent" {
1087 bg_available = true;
1088 }
1089 if name != "agent" {
1090 continue;
1091 }
1092 running_agents += 1;
1093 let (_, description) = mermaid_domain::display_info_for(call);
1094 let live = state.ui.live_tool_status.get(&call.call_id);
1095 rows.push(widgets::AgentPanelRow {
1096 description,
1097 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1098 tokens: live.map_or(0, |l| l.tokens),
1099 elapsed_secs: elapsed,
1100 backgrounded: false,
1101 });
1102 }
1103 }
1104 for agent in &state.runtime.background_agents {
1105 rows.push(widgets::AgentPanelRow {
1106 description: agent.description.clone(),
1107 activity: agent.activity.clone(),
1108 tokens: agent.tokens,
1109 elapsed_secs: elapsed_since(agent.started),
1110 backgrounded: true,
1111 });
1112 }
1113 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1114 if running_agents == 1 {
1115 "Running 1 agent".to_string()
1116 } else {
1117 format!("Running {running_agents} agents")
1118 }
1119 });
1120 (rows, status_override, bg_available)
1121}
1122
1123fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1128 None
1129}
1130
1131#[cfg(test)]
1136pub(crate) fn render_frame(
1137 state: &State,
1138 rstate: &mut RenderCache,
1139 width: u16,
1140 height: u16,
1141) -> String {
1142 use ratatui::Terminal;
1143 use ratatui::backend::TestBackend;
1144 let backend = TestBackend::new(width, height);
1145 let mut terminal = Terminal::new(backend).expect("terminal");
1146 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1147 let buf = terminal.backend().buffer();
1148 let mut out = String::new();
1149 for y in 0..buf.area.height {
1150 for x in 0..buf.area.width {
1151 out.push_str(buf[(x, y)].symbol());
1152 }
1153 out.push('\n');
1154 }
1155 out
1156}
1157
1158#[cfg(test)]
1162mod snapshots;
1163
1164#[cfg(test)]
1166mod bench;
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use mermaid_domain::Config;
1172 use mermaid_domain::{State, TurnState};
1173 use ratatui::Terminal;
1174 use ratatui::backend::TestBackend;
1175 use std::path::PathBuf;
1176
1177 fn mock_state() -> State {
1178 State::new(
1179 Config::default(),
1180 PathBuf::from("/tmp/p"),
1181 "ollama/test".to_string(),
1182 chrono::Local::now(),
1183 PathBuf::from("/tmp"),
1184 )
1185 }
1186
1187 fn test_cache() -> RenderCache {
1190 RenderCache::new("testhost".to_string(), "testuser".to_string())
1191 }
1192
1193 fn render_to_string(state: &State) -> String {
1194 render_frame(state, &mut test_cache(), 80, 24)
1195 }
1196
1197 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1198 let backend = TestBackend::new(80, 24);
1199 let mut terminal = Terminal::new(backend).expect("terminal");
1200 let mut rstate = test_cache();
1201 terminal
1202 .draw(|f| render(state, &mut rstate, f))
1203 .expect("draw");
1204 terminal.backend().buffer().clone()
1205 }
1206
1207 #[test]
1208 fn theme_choice_changes_colors_never_glyphs() {
1209 let mut state = mock_state();
1213 state
1214 .session
1215 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1216 let dark = render_to_string(&state);
1217 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1218 let light = render_to_string(&state);
1219 assert_eq!(dark, light, "light theme changed glyphs");
1220 state.ui.no_color = true;
1221 let plain = render_to_string(&state);
1222 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1223 }
1224
1225 #[test]
1226 fn theme_memo_swaps_palette_on_state_change() {
1227 let mut state = mock_state();
1228 let mut rstate = test_cache();
1229 render_frame(&state, &mut rstate, 80, 24);
1230 assert_eq!(rstate.theme.name, "Dark");
1231 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1232 render_frame(&state, &mut rstate, 80, 24);
1233 assert_eq!(rstate.theme.name, "Light");
1234 state.ui.no_color = true;
1236 render_frame(&state, &mut rstate, 80, 24);
1237 assert_eq!(rstate.theme.name, "Plain");
1238 }
1239
1240 #[test]
1241 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1242 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1243
1244 let mut state = mock_state();
1245 let call_id = ToolCallId(7);
1246 state.turn = TurnState::ExecutingTools {
1247 id: TurnId(1),
1248 started: std::time::SystemTime::now(),
1249 calls: vec![PendingToolCall {
1250 call_id,
1251 source: mermaid_model::models::tool_call::ToolCall {
1252 id: None,
1253 function: mermaid_model::models::tool_call::FunctionCall {
1254 name: "agent".to_string(),
1255 arguments: serde_json::json!({"description": "explore crates"}),
1256 },
1257 },
1258 }],
1259 outcomes: vec![None],
1260 };
1261 state.ui.live_tool_status.insert(
1262 call_id,
1263 LiveToolStatus {
1264 activity: "read_file…".to_string(),
1265 tokens: 12_300,
1266 },
1267 );
1268
1269 let live = build_live_messages(
1272 &[],
1273 &state.turn,
1274 chrono::Local::now(),
1275 mermaid_runtime::HostShell::Posix,
1276 );
1277 assert!(
1278 live.is_empty(),
1279 "a pending agent call must not synthesize a transcript row"
1280 );
1281 let (rows, override_text, bg_available) = agent_panel_data(&state);
1282 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1283 assert!(bg_available, "agents are detachable via ctrl+b");
1284 assert_eq!(rows.len(), 1);
1285 assert_eq!(rows[0].description, "explore crates");
1286 assert_eq!(rows[0].activity, "read_file…");
1287 assert_eq!(rows[0].tokens, 12_300);
1288 assert!(!rows[0].backgrounded);
1289 }
1290
1291 #[test]
1292 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1293 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1294
1295 let mut state = mock_state();
1296 let exec_id = ToolCallId(8);
1297 let agent_id = ToolCallId(9);
1298 let call = |id, name: &str, args| PendingToolCall {
1299 call_id: id,
1300 source: mermaid_model::models::tool_call::ToolCall {
1301 id: None,
1302 function: mermaid_model::models::tool_call::FunctionCall {
1303 name: name.to_string(),
1304 arguments: args,
1305 },
1306 },
1307 };
1308 state.turn = TurnState::ExecutingTools {
1309 id: TurnId(1),
1310 started: std::time::SystemTime::now(),
1311 calls: vec![
1312 call(
1313 exec_id,
1314 "execute_command",
1315 serde_json::json!({"command": "cargo test"}),
1316 ),
1317 call(
1318 agent_id,
1319 "agent",
1320 serde_json::json!({"description": "audit docs"}),
1321 ),
1322 ],
1323 outcomes: vec![None, None],
1324 };
1325 state.ui.live_tool_status.insert(
1326 exec_id,
1327 LiveToolStatus {
1328 activity: String::new(),
1329 tokens: 0,
1330 },
1331 );
1332
1333 let live = build_live_messages(
1336 &[],
1337 &state.turn,
1338 chrono::Local::now(),
1339 mermaid_runtime::HostShell::Posix,
1340 );
1341 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1342 let actions = &live[0].actions;
1343 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1344 assert_eq!(actions[0].action_type, "Bash");
1345 assert_eq!(actions[0].target, "cargo test");
1346 assert!(matches!(
1347 actions[0].result,
1348 mermaid_domain::ActionResult::Running
1349 ));
1350 let (rows, override_text, _) = agent_panel_data(&state);
1351 assert_eq!(override_text, None);
1352 assert_eq!(rows.len(), 1);
1353 }
1354
1355 #[test]
1356 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1357 use mermaid_domain::{GenPhase, TurnId};
1358 use mermaid_model::models::ChatMessage;
1359 use std::borrow::Cow;
1360 use std::time::SystemTime;
1361
1362 let committed = vec![ChatMessage::user("hi")];
1363 let now = chrono::Local::now();
1364
1365 let idle = build_live_messages(
1367 &committed,
1368 &TurnState::Idle,
1369 now,
1370 mermaid_runtime::HostShell::Posix,
1371 );
1372 assert!(matches!(idle, Cow::Borrowed(_)));
1373 assert_eq!(idle.len(), 1);
1374
1375 let turn = TurnState::Generating {
1378 id: TurnId(1),
1379 started: SystemTime::now(),
1380 partial_text: "draft".to_string(),
1381 partial_reasoning: String::new(),
1382 tokens: 0,
1383 phase: GenPhase::Sending,
1384 provider_continuation: None,
1385 pending_tool_calls: Vec::new(),
1386 continuation: false,
1387 };
1388 let live = build_live_messages(&committed, &turn, now, mermaid_runtime::HostShell::Posix);
1389 assert!(matches!(live, Cow::Owned(_)));
1390 assert_eq!(live.len(), 2);
1391 assert_eq!(live[1].timestamp, now);
1392 }
1393
1394 fn kinded(
1395 mut msg: mermaid_model::models::ChatMessage,
1396 kind: mermaid_model::models::ChatMessageKind,
1397 ) -> mermaid_model::models::ChatMessage {
1398 msg.kind = kind;
1399 msg
1400 }
1401
1402 #[test]
1403 fn stitch_committed_merges_chain_and_hides_nudges() {
1404 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1405 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1406 part1.thinking = Some("first trace".to_string());
1407 let mut part2 = kinded(
1409 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1410 ChatMessageKind::Continuation,
1411 );
1412 part2.thinking = Some("second trace".to_string());
1413 let committed = vec![
1414 ChatMessage::user("audit the widget"),
1415 part1,
1416 kinded(
1417 ChatMessage::system("resume nudge"),
1418 ChatMessageKind::RecoveryNudge,
1419 ),
1420 part2,
1421 ];
1422
1423 assert!(needs_stitch(&committed, &TurnState::Idle));
1424 let stitched = stitch_committed(&committed);
1425 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1426 assert_eq!(
1427 stitched[1].content,
1428 "The audit found three issues in the resolver, and here is the fix.",
1429 "contents merge with the resume echo trimmed"
1430 );
1431 assert_eq!(
1432 stitched[1].thinking.as_deref(),
1433 Some("first trace\n\nsecond trace"),
1434 "both reasoning segments survive in order"
1435 );
1436 assert!(
1437 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1438 "nudges never render"
1439 );
1440 }
1441
1442 #[test]
1445 fn context_markers_are_hidden_from_the_transcript() {
1446 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1447 let committed = vec![
1448 ChatMessage::user("plan this"),
1449 kinded(
1450 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1451 ChatMessageKind::ContextMarker,
1452 ),
1453 ChatMessage::assistant("Grounding first."),
1454 ];
1455 assert!(
1460 !needs_stitch(&committed, &TurnState::Idle),
1461 "a marker alone must not defeat the zero-copy path",
1462 );
1463 let stitched = stitch_committed(&committed);
1465 assert_eq!(stitched.len(), 2, "user + assistant only");
1466 assert!(
1467 !stitched
1468 .iter()
1469 .any(|m| m.content.contains("Plan mode is now ON")),
1470 "markers never render"
1471 );
1472 }
1473
1474 #[test]
1475 fn stitch_refuses_non_bubble_predecessor() {
1476 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1477 let committed = vec![
1481 kinded(
1482 ChatMessage::assistant("checkpoint summary"),
1483 ChatMessageKind::ContextCheckpoint,
1484 ),
1485 kinded(
1486 ChatMessage::assistant("orphaned continuation"),
1487 ChatMessageKind::Continuation,
1488 ),
1489 ];
1490 let stitched = stitch_committed(&committed);
1491 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1492 assert_eq!(stitched[1].content, "orphaned continuation");
1493 }
1494
1495 #[test]
1496 fn needs_stitch_is_false_for_plain_sessions() {
1497 use mermaid_model::models::ChatMessage;
1498 let committed = vec![
1501 ChatMessage::user("hi"),
1502 ChatMessage::assistant("hello"),
1503 ChatMessage::system("note"),
1504 ];
1505 assert!(!needs_stitch(&committed, &TurnState::Idle));
1506 }
1507
1508 #[test]
1516 fn a_live_continuation_still_forces_the_stitch() {
1517 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1518 let committed = vec![
1519 ChatMessage::user("write it"),
1520 ChatMessage::assistant("first half"),
1521 kinded(
1522 ChatMessage::system("output limit — continuing"),
1523 ChatMessageKind::RecoveryNudge,
1524 ),
1525 ];
1526 let streaming = TurnState::Generating {
1527 id: mermaid_domain::TurnId(1),
1528 started: std::time::SystemTime::UNIX_EPOCH,
1529 partial_text: "first half and the rest".to_string(),
1530 partial_reasoning: String::new(),
1531 tokens: 0,
1532 phase: mermaid_domain::GenPhase::Streaming,
1533 provider_continuation: None,
1534 pending_tool_calls: Vec::new(),
1535 continuation: true,
1536 };
1537 assert!(
1538 needs_stitch(&committed, &streaming),
1539 "a live continuation needs the nudge stripped to find its bubble",
1540 );
1541 let stitched = stitch_committed(&committed);
1543 assert!(
1544 stitched.last().is_some_and(mergeable_into),
1545 "the stitched tail is the assistant bubble the partial merges into",
1546 );
1547 }
1548
1549 #[test]
1550 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1551 use mermaid_domain::{GenPhase, TurnId};
1552 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1553
1554 let committed = vec![ChatMessage::assistant(
1555 "the fix lands in the resolver module",
1556 )];
1557 let turn = TurnState::Generating {
1558 id: TurnId(2),
1559 started: std::time::SystemTime::now(),
1560 partial_text: "in the resolver module, specifically the clamp".to_string(),
1561 partial_reasoning: String::new(),
1562 tokens: 0,
1563 phase: GenPhase::Streaming,
1564 provider_continuation: None,
1565 pending_tool_calls: Vec::new(),
1566 continuation: true,
1567 };
1568 let live = build_live_messages(
1569 &committed,
1570 &turn,
1571 chrono::Local::now(),
1572 mermaid_runtime::HostShell::Posix,
1573 );
1574 let streamed = live.last().expect("pseudo-message appended");
1575 assert_eq!(
1576 streamed.kind,
1577 ChatMessageKind::Continuation,
1578 "the live half is stamped so the widget draws it prefix-less"
1579 );
1580 assert_eq!(
1581 streamed.content, ", specifically the clamp",
1582 "the leading resume echo is trimmed against the committed tail"
1583 );
1584 }
1585
1586 #[test]
1587 fn auto_continued_reply_renders_as_one_bubble() {
1588 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1589 let mut s = mock_state();
1590 s.session.append(ChatMessage::user("audit"), s.now);
1591 s.session
1592 .append(ChatMessage::assistant("part one of the reply"), s.now);
1593 s.session.append(
1594 kinded(
1595 ChatMessage::system("output limit — continuing"),
1596 ChatMessageKind::RecoveryNudge,
1597 ),
1598 s.now,
1599 );
1600 s.session.append(
1601 kinded(
1602 ChatMessage::assistant("and part two lands here"),
1603 ChatMessageKind::Continuation,
1604 ),
1605 s.now,
1606 );
1607
1608 let out = render_to_string(&s);
1609 assert!(out.contains("part one of the reply"));
1610 assert!(out.contains("and part two lands here"));
1611 assert!(
1612 !out.contains("continuing"),
1613 "the recovery nudge never renders"
1614 );
1615 assert_eq!(
1616 out.matches('●').count(),
1617 1,
1618 "both halves share one assistant bullet:\n{out}"
1619 );
1620 }
1621
1622 #[test]
1623 fn streaming_continuation_renders_without_fresh_bullet() {
1624 use mermaid_domain::{GenPhase, TurnId};
1625 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1626 let mut s = mock_state();
1627 s.session.append(ChatMessage::user("audit"), s.now);
1628 s.session
1629 .append(ChatMessage::assistant("part one of the reply"), s.now);
1630 s.session.append(
1631 kinded(
1632 ChatMessage::system("output limit — continuing"),
1633 ChatMessageKind::RecoveryNudge,
1634 ),
1635 s.now,
1636 );
1637 s.turn = TurnState::Generating {
1638 id: TurnId(3),
1639 started: std::time::SystemTime::now(),
1640 partial_text: "and part two streams in".to_string(),
1641 partial_reasoning: String::new(),
1642 tokens: 0,
1643 phase: GenPhase::Streaming,
1644 provider_continuation: None,
1645 pending_tool_calls: Vec::new(),
1646 continuation: true,
1647 };
1648
1649 let out = render_to_string(&s);
1650 assert!(out.contains("part one of the reply"));
1651 assert!(out.contains("and part two streams in"));
1652 assert!(!out.contains("continuing"), "live nudge hidden too");
1653 assert_eq!(
1654 out.matches('●').count(),
1655 1,
1656 "the streaming half joins the committed bubble:\n{out}"
1657 );
1658 }
1659
1660 #[test]
1661 fn user_prompt_renders_with_highlight_band() {
1662 let mut s = mock_state();
1663 s.session.append(
1664 mermaid_model::models::ChatMessage::user("hello there"),
1665 s.now,
1666 );
1667 let buf = render_to_buffer(&s);
1668 let band_bg = crate::render::theme::Theme::dark()
1669 .colors
1670 .user_message_background
1671 .to_color();
1672 let y = (0..buf.area.height)
1674 .find(|&y| {
1675 (0..buf.area.width)
1676 .map(|x| buf[(x, y)].symbol())
1677 .collect::<String>()
1678 .contains("hello there")
1679 })
1680 .expect("user prompt should render");
1681 let banded = (0..buf.area.width)
1684 .filter(|&x| buf[(x, y)].bg == band_bg)
1685 .count();
1686 assert!(
1687 banded >= (buf.area.width as usize) * 3 / 4,
1688 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1689 buf.area.width
1690 );
1691 }
1692
1693 #[test]
1694 fn idle_state_renders_cwd_and_model_footer() {
1695 let s = mock_state();
1696 let frame = render_to_string(&s);
1697 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1699 assert!(frame.contains("ollama/test"));
1700 }
1701
1702 #[test]
1703 fn status_line_appears_during_generating() {
1704 let mut s = mock_state();
1705 s.turn = mermaid_domain::transition::start_generating(
1706 mermaid_domain::TurnId(1),
1707 std::time::SystemTime::now(),
1708 );
1709 let frame = render_to_string(&s);
1710 assert!(
1711 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1712 "expected generation status in frame"
1713 );
1714 }
1715
1716 #[test]
1717 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1718 use mermaid_domain::PendingToolCall;
1719 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1720 let mut s = mock_state();
1721 let call = PendingToolCall {
1722 call_id: mermaid_domain::ToolCallId(1),
1723 source: ModelToolCall {
1724 id: Some("c1".to_string()),
1725 function: FunctionCall {
1726 name: "execute_command".to_string(),
1727 arguments: serde_json::json!({"command": "npm run dev"}),
1728 },
1729 },
1730 };
1731 s.turn = TurnState::ExecutingTools {
1732 id: mermaid_domain::TurnId(1),
1733 started: std::time::SystemTime::now(),
1734 calls: vec![call],
1735 outcomes: vec![None],
1736 };
1737 let frame = render_to_string(&s);
1738 assert!(frame.contains("Running tools..."), "got: {frame}");
1741 assert!(
1742 !frame.contains("Running tools:"),
1743 "status line must not carry tool detail; got: {frame}"
1744 );
1745 assert!(
1747 frame.contains("npm run dev"),
1748 "transcript must show the in-flight call's action row; got: {frame}"
1749 );
1750 }
1751
1752 #[test]
1753 fn pending_question_and_agent_calls_get_no_transcript_row() {
1754 use mermaid_domain::PendingToolCall;
1755 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1756 let mut s = mock_state();
1757 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1758 call_id: mermaid_domain::ToolCallId(id),
1759 source: ModelToolCall {
1760 id: Some(format!("c{id}")),
1761 function: FunctionCall {
1762 name: name.to_string(),
1763 arguments: args,
1764 },
1765 },
1766 };
1767 s.turn = TurnState::ExecutingTools {
1768 id: mermaid_domain::TurnId(1),
1769 started: std::time::SystemTime::now(),
1770 calls: vec![
1771 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1772 mk(
1773 2,
1774 "agent",
1775 serde_json::json!({"description": "scan the repo"}),
1776 ),
1777 ],
1778 outcomes: vec![None, None],
1779 };
1780 let frame = render_to_string(&s);
1781 assert!(
1784 !frame.contains("ask_user_question"),
1785 "pending question must not surface as a transcript row or status text; got: {frame}"
1786 );
1787 }
1788
1789 #[test]
1790 fn status_line_appears_during_tool_execution_and_shows_queue() {
1791 let mut s = mock_state();
1792 s.turn = TurnState::ExecutingTools {
1793 id: mermaid_domain::TurnId(1),
1794 started: std::time::SystemTime::now(),
1795 calls: Vec::new(),
1796 outcomes: Vec::new(),
1797 };
1798 s.ui.queued_messages
1799 .push_back(mermaid_domain::QueuedMessage {
1800 text: "please steer this".to_string(),
1801 attachment_ids: Vec::new(),
1802 });
1803 let frame = render_to_string(&s);
1804 assert!(frame.contains("Running tools"), "expected tool status");
1805 assert!(
1806 frame.contains("please steer this"),
1807 "queued busy input must be visible"
1808 );
1809 }
1810
1811 #[test]
1812 fn reasoning_blocks_are_collapsed_by_default() {
1813 let mut s = mock_state();
1814 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1815 first_msg.thinking = Some("first private chain of thought".to_string());
1816 s.session.append(first_msg, s.now);
1817 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1818 second_msg.thinking = Some("second private chain of thought".to_string());
1819 s.session.append(second_msg, s.now);
1820 let frame = render_to_string(&s);
1821 assert!(!frame.contains("Reasoning hidden"));
1823 assert!(frame.contains("first visible answer"));
1824 assert!(frame.contains("second visible answer"));
1825 assert!(!frame.contains("first private chain of thought"));
1826 assert!(!frame.contains("second private chain of thought"));
1827 }
1828
1829 #[test]
1833 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1834 let mut s = mock_state();
1835 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1836 msg.thinking = Some("private chain of thought".to_string());
1837 msg.actions.push(mermaid_domain::ActionDisplay {
1838 action_type: "Bash".to_string(),
1839 target: "dir".to_string(),
1840 result: mermaid_domain::ActionResult::Success {
1841 output: "ok".to_string(),
1842 images: None,
1843 },
1844 details: mermaid_domain::ActionDetails::Simple,
1845 duration_seconds: Some(0.015),
1846 metadata: None,
1847 });
1848 s.session.append(msg, s.now);
1849 let frame = render_to_string(&s);
1850 assert!(
1851 !frame.contains("Reasoning hidden"),
1852 "no reasoning-hidden placeholder"
1853 );
1854 assert!(
1855 frame.contains("Bash"),
1856 "the action still renders even though reasoning is hidden"
1857 );
1858 }
1859
1860 #[test]
1861 fn committed_message_appears_in_chat_pane() {
1862 let mut s = mock_state();
1863 s.session.append(
1864 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1865 s.now,
1866 );
1867 let frame = render_to_string(&s);
1868 assert!(frame.contains("unique-user-token-xyz"));
1869 }
1870
1871 #[test]
1872 fn palette_renders_when_input_starts_with_slash() {
1873 let mut s = mock_state();
1874 s.ui.input_buffer = "/help".to_string();
1875 s.ui.input_cursor = 5;
1876 let frame = render_to_string(&s);
1877 assert!(frame.contains("help"));
1879 }
1880
1881 #[test]
1882 fn status_line_helper_maps_idle_to_idle() {
1883 assert_eq!(
1884 GenerationStatus::from_turn(&TurnState::Idle),
1885 GenerationStatus::Idle
1886 );
1887 }
1888
1889 #[test]
1901 fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
1902 use ratatui::Terminal;
1903 use ratatui::backend::TestBackend;
1904
1905 let text = "Create a language that. Your goal is up to you.";
1906 for width in [24u16, 30, 40, 55] {
1907 for n in 0..=text.len() {
1908 let Some(prefix) = text.get(..n) else {
1911 continue;
1912 };
1913 let mut state = mock_state();
1914 state.ui.input_buffer = prefix.to_string();
1915 state.ui.input_cursor = prefix.len();
1916
1917 let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
1918 let mut rstate = test_cache();
1919 terminal
1920 .draw(|f| render(&state, &mut rstate, f))
1921 .expect("draw");
1922 let cursor = terminal.get_cursor_position().expect("cursor");
1923 let buf = terminal.backend().buffer().clone();
1924
1925 let row_at = |y: u16| {
1926 (0..buf.area.width)
1927 .map(|x| buf[(x, y)].symbol())
1928 .collect::<String>()
1929 };
1930 let caret_row = row_at(cursor.y);
1931
1932 assert!(
1935 !caret_row.contains('─'),
1936 "caret on the box border at width {width}, prefix {prefix:?}\n\
1937 row {}: |{caret_row}|",
1938 cursor.y
1939 );
1940
1941 let content_width = width.saturating_sub(2) as usize;
1943 let rows = widgets::rendered_row_count(prefix, content_width);
1944 assert!(
1945 rows <= 5,
1946 "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
1947 );
1948 if let Some(last) = prefix.split_whitespace().next_back() {
1949 let frame: String = (0..buf.area.height)
1950 .map(row_at)
1951 .collect::<Vec<_>>()
1952 .join("\n");
1953 assert!(
1954 frame.contains(last),
1955 "last word {last:?} clipped at width {width}, \
1956 prefix {prefix:?}\n{frame}"
1957 );
1958 }
1959 }
1960 }
1961 }
1962}