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;
295 let status_line_height = (status_lines.len() as u16)
296 .min(14)
297 .min(frame.area().height.saturating_sub(status_reserve));
298
299 let tasks_store = &state.session.conversation.tasks;
304 let tasks_attached = status_line_height > 0;
305 let tasks_zone_height = if question_modal_open {
306 0
307 } else if widgets::tasks_visible(
308 tasks_store,
309 &state.turn,
310 state.ui.tasks_collapsed,
311 tasks_attached,
312 ) {
313 widgets::tasks_height(tasks_store, state.ui.tasks_collapsed).min(
314 frame
315 .area()
316 .height
317 .saturating_sub(status_reserve + status_line_height),
318 )
319 } else {
320 0
321 };
322
323 let confirm_open =
333 approval_item.is_none() && question_item.is_none() && state.confirm.is_some();
334 let conv_list_open = approval_item.is_none()
335 && question_item.is_none()
336 && !confirm_open
337 && matches!(
338 state.ui.mode,
339 mermaid_domain::UiMode::ConversationList { .. }
340 );
341 let rewind_open = approval_item.is_none()
342 && question_item.is_none()
343 && !confirm_open
344 && matches!(state.ui.mode, mermaid_domain::UiMode::RewindPicker { .. });
345 let plan_config_open = approval_item.is_none()
346 && question_item.is_none()
347 && !confirm_open
348 && matches!(state.ui.mode, mermaid_domain::UiMode::PlanConfig { .. });
349 let model_picker_open = approval_item.is_none()
350 && question_item.is_none()
351 && !confirm_open
352 && matches!(state.ui.mode, mermaid_domain::UiMode::ModelPicker { .. });
353 let file_picker_open = approval_item.is_none()
354 && question_item.is_none()
355 && !confirm_open
356 && !conv_list_open
357 && !rewind_open
358 && !plan_config_open
359 && state.ui.file_picker_open();
360 let palette_open = approval_item.is_none()
361 && question_item.is_none()
362 && !confirm_open
363 && !conv_list_open
364 && !file_picker_open
365 && state.ui.input_buffer.starts_with('/');
366 let bottom_height = if let Some(item) = approval_item {
367 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
369 2 + body_lines + 1 + 3
370 } else if let Some(qset) = question_item {
371 widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
374 } else if confirm_open {
375 6
376 } else if conv_list_open || rewind_open {
377 12
378 } else if plan_config_open {
379 widgets::PLAN_CONFIG_HEIGHT
380 } else if model_picker_open {
381 widgets::MODEL_PICKER_HEIGHT
382 } else if file_picker_open {
383 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
384 (rows as u16) + 2
385 } else if palette_open {
386 let typed = state
387 .ui
388 .input_buffer
389 .trim_start_matches('/')
390 .split_whitespace()
391 .next()
392 .unwrap_or("");
393 let row_count =
394 mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands)
395 .len()
396 .clamp(1, 8);
397 (row_count as u16) + 2
398 } else {
399 2
400 };
401
402 use ratatui::layout::{Constraint, Direction, Layout};
406 let chunks = Layout::default()
407 .direction(Direction::Vertical)
408 .constraints([
409 Constraint::Min(10),
410 Constraint::Length(status_line_height),
411 Constraint::Length(tasks_zone_height),
412 Constraint::Length(input_height),
413 Constraint::Length(bottom_height),
414 ])
415 .split(frame.area());
416
417 let chat_area = chunks[0].inner(Margin {
419 horizontal: 1,
420 vertical: 0,
421 });
422 let toast = active_toast(state);
427 let (chat_area, toast_area) = match toast {
428 Some(_) if chat_area.height > 1 => (
429 Rect {
430 height: chat_area.height - 1,
431 ..chat_area
432 },
433 Some(Rect {
434 y: chat_area.y + chat_area.height - 1,
435 height: 1,
436 ..chat_area
437 }),
438 ),
439 _ => (chat_area, None),
440 };
441 let committed = state.session.messages();
446 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
447 let key = stitch_fingerprint(committed);
448 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
449 rstate.stitched = Some(StitchedMemo {
450 key,
451 messages: stitch_committed(committed),
452 });
453 }
454 &rstate
455 .stitched
456 .as_ref()
457 .expect("stitched memo populated above")
458 .messages
459 } else {
460 committed
461 };
462 let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
463 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
466 let chat_widget = ChatWidget {
467 messages: live_messages.as_ref(),
468 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
469 theme: &rstate.theme,
470 wrapped_line_cache: &mut rstate.wrapped_line_cache,
471 show_reasoning: state.ui.show_reasoning,
472 blink_on,
473 today: state.now.date_naive(),
474 };
475 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
476
477 if let (Some(text), Some(area)) = (toast, toast_area) {
480 frame.render_widget(
481 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
482 text,
483 Style::new().fg(rstate.theme.colors.info.to_color()),
484 )))
485 .alignment(ratatui::layout::Alignment::Right),
486 area,
487 );
488 }
489
490 if !status_lines.is_empty() {
493 let status_area = chunks[1].inner(Margin {
494 horizontal: 1,
495 vertical: 0,
496 });
497 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
498 }
499
500 if tasks_zone_height > 0 {
502 let tasks_area = chunks[2].inner(Margin {
503 horizontal: 1,
504 vertical: 0,
505 });
506 let lines = widgets::build_task_lines(
507 tasks_store,
508 state.ui.tasks_collapsed,
509 tasks_attached,
510 tasks_area.width,
511 &rstate.theme,
512 );
513 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
514 }
515
516 if !question_modal_open {
520 let input_widget = InputWidget {
521 input: state.ui.input_buffer.as_str(),
522 showing_command_hints: state.ui.input_buffer.starts_with('/'),
523 theme: &rstate.theme,
524 reasoning_active: state.session.reasoning != ReasoningLevel::None,
525 exit_armed: exit_armed(state),
526 rewind_armed: rewind_armed(state),
527 };
528 let mut input_widget_state = InputState {
529 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
530 };
531 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
532
533 let input_area = chunks[3];
535 let content_width = input_area.width.saturating_sub(2) as usize;
536 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
537 &state.ui.input_buffer,
538 state.ui.input_cursor.min(state.ui.input_buffer.len()),
539 content_width,
540 );
541 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
542 }
543
544 let requested = state.session.reasoning;
548 let effective = match supported_reasoning_for(state) {
549 Some(ReasoningCapability::Levels(supp)) => {
550 nearest_effort(requested, &supp).unwrap_or(requested)
551 },
552 _ => requested,
553 };
554 let requested_level = if effective == requested {
555 None
556 } else {
557 Some(requested)
558 };
559
560 if let Some(item) = state.pending_approval.front() {
563 use widgets::ApprovalModalWidget;
564 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 } else if let Some(qset) = state.pending_question.front() {
586 use widgets::QuestionModalWidget;
587 let widget = QuestionModalWidget {
588 theme: &rstate.theme,
589 set: qset,
590 width: chunks[4].width,
591 };
592 frame.render_widget(widget, chunks[4]);
593 } else if let Some(confirm) = &state.confirm {
594 use widgets::ApprovalModalWidget;
595 let widget = ApprovalModalWidget {
596 theme: &rstate.theme,
597 title: "Confirm".to_string(),
598 body: confirm.prompt.as_str(),
599 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
600 selected_index: None,
601 accent: rstate.theme.colors.warning.to_color(),
602 };
603 frame.render_widget(widget, chunks[4]);
604 } else if let mermaid_domain::UiMode::ModelPicker {
605 candidates,
606 query,
607 cursor,
608 loading,
609 } = &state.ui.mode
610 {
611 use widgets::ModelPickerWidget;
612 let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
613 let widget = ModelPickerWidget {
614 theme: &rstate.theme,
615 matches: &matches,
616 query,
617 cursor: *cursor,
618 loading: *loading,
619 current: &state.session.model_id,
620 };
621 frame.render_widget(widget, chunks[4]);
622 } else if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
623 use widgets::ConversationListWidget;
624 let widget = ConversationListWidget {
625 theme: &rstate.theme,
626 candidates,
627 cursor: *cursor,
628 };
629 frame.render_widget(widget, chunks[4]);
630 } else if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
631 use widgets::RewindPickerWidget;
632 let widget = RewindPickerWidget {
633 theme: &rstate.theme,
634 candidates,
635 cursor: *cursor,
636 };
637 frame.render_widget(widget, chunks[4]);
638 } else if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
639 use widgets::PlanConfigWidget;
640 let widget = PlanConfigWidget {
641 theme: &rstate.theme,
642 plan: &state.settings.plan,
643 session_model: &state.session.model_id,
644 cursor: *cursor,
645 };
646 frame.render_widget(widget, chunks[4]);
647 } else if file_picker_open {
648 use widgets::FilePickerWidget;
649 let widget = FilePickerWidget {
650 theme: &rstate.theme,
651 matches: &state.ui.file_picker_matches,
652 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
653 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
654 };
655 frame.render_widget(widget, chunks[4]);
656 } else if palette_open {
657 let typed = state
658 .ui
659 .input_buffer
660 .trim_start_matches('/')
661 .split_whitespace()
662 .next()
663 .unwrap_or("");
664 let entries = mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands);
665 let palette_widget = SlashPaletteWidget {
666 theme: &rstate.theme,
667 entries,
668 selected_index: state.ui.palette_cursor.unwrap_or(0),
669 };
670 frame.render_widget(palette_widget, chunks[4]);
671 } else {
672 let cwd = state.cwd.display().to_string();
673 let status_widget = StatusWidget {
674 theme: &rstate.theme,
675 working_dir: &cwd,
676 hostname: &rstate.hostname,
677 username: &rstate.username,
678 version: &rstate.version,
679 context_usage: state.session.context_usage.as_ref(),
680 model_name: &state.session.model_id,
681 reasoning_level: effective,
682 requested_level,
683 safety_mode: state.session.safety_mode,
686 };
687 frame.render_widget(status_widget, chunks[4]);
688 }
689}
690
691pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
698 prev.role == mermaid_model::models::MessageRole::Assistant
699 && matches!(
700 prev.kind,
701 mermaid_model::models::ChatMessageKind::Normal
702 | mermaid_model::models::ChatMessageKind::Continuation
703 )
704 && prev.tool_calls.is_none()
705}
706
707fn chat_content_key(
727 state: &State,
728 base: &[mermaid_model::models::ChatMessage],
729 live: &[mermaid_model::models::ChatMessage],
730 blink_on: bool,
731) -> u64 {
732 use std::hash::{Hash, Hasher};
733 let mut h = rustc_hash::FxHasher::default();
734 state.session.conversation.revision().hash(&mut h);
735 base.len().hash(&mut h);
738 for msg in live.iter().skip(base.len()) {
739 msg.content.hash(&mut h);
740 msg.thinking.hash(&mut h);
741 std::mem::discriminant(&msg.kind).hash(&mut h);
742 msg.actions.len().hash(&mut h);
743 for action in &msg.actions {
744 action.action_type.hash(&mut h);
745 action.target.hash(&mut h);
746 std::mem::discriminant(&action.result).hash(&mut h);
747 }
748 }
749 if !matches!(state.turn, TurnState::Idle) {
750 blink_on.hash(&mut h);
751 }
752 h.finish()
753}
754
755fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
776 let live_continuation = matches!(
777 turn,
778 TurnState::Generating { continuation, .. } if *continuation
779 );
780 live_continuation
781 || committed
782 .iter()
783 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
784}
785
786fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
792 use std::hash::{Hash, Hasher};
793 use std::mem::discriminant;
794
795 let mut h = rustc_hash::FxHasher::default();
796 committed.len().hash(&mut h);
797 for msg in committed {
798 msg.content.hash(&mut h);
799 msg.thinking.hash(&mut h);
800 msg.timestamp.timestamp().hash(&mut h);
801 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
802 msg.image_numbers
803 .as_ref()
804 .map_or(0, |v| v.len())
805 .hash(&mut h);
806 discriminant(&msg.role).hash(&mut h);
807 discriminant(&msg.kind).hash(&mut h);
808 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
809 msg.actions.len().hash(&mut h);
817 for action in &msg.actions {
818 action.action_type.hash(&mut h);
819 action.target.hash(&mut h);
820 discriminant(&action.result).hash(&mut h);
821 discriminant(&action.details).hash(&mut h);
822 action.duration_seconds.map(f64::to_bits).hash(&mut h);
823 if let Some(meta) = &action.metadata {
824 meta.lines_added.hash(&mut h);
825 meta.lines_removed.hash(&mut h);
826 meta.diff_truncated.hash(&mut h);
827 meta.display_diff.as_ref().map(String::len).hash(&mut h);
828 }
829 }
830 }
831 h.finish()
832}
833
834fn stitch_committed(
846 committed: &[mermaid_model::models::ChatMessage],
847) -> Vec<mermaid_model::models::ChatMessage> {
848 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
849 for msg in committed {
850 if matches!(
851 msg.kind,
852 mermaid_model::models::ChatMessageKind::RecoveryNudge
853 | mermaid_model::models::ChatMessageKind::ContextMarker
854 ) {
855 continue;
856 }
857 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
858 && let Some(prev) = out.last_mut()
859 && mergeable_into(prev)
860 {
861 merge_continuation(prev, msg);
862 continue;
863 }
864 out.push(msg.clone());
865 }
866 out
867}
868
869fn merge_continuation(
873 prev: &mut mermaid_model::models::ChatMessage,
874 cont: &mermaid_model::models::ChatMessage,
875) {
876 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
877 prev.content.push_str(&cont.content[skip..]);
878 if let Some(cont_thinking) = &cont.thinking {
879 match &mut prev.thinking {
880 Some(t) => {
881 t.push_str("\n\n");
882 t.push_str(cont_thinking);
883 },
884 None => prev.thinking = Some(cont_thinking.clone()),
885 }
886 }
887 prev.actions.extend(cont.actions.iter().cloned());
888 if let Some(imgs) = &cont.images {
889 prev.images
890 .get_or_insert_with(Vec::new)
891 .extend(imgs.iter().cloned());
892 }
893 if let Some(nums) = &cont.image_numbers {
894 prev.image_numbers
895 .get_or_insert_with(Vec::new)
896 .extend(nums.iter().copied());
897 }
898 if cont.tool_calls.is_some() {
901 prev.tool_calls = cont.tool_calls.clone();
902 }
903}
904
905fn build_live_messages<'a>(
925 committed: &'a [mermaid_model::models::ChatMessage],
926 turn: &TurnState,
927 now: chrono::DateTime<chrono::Local>,
928 host_shell: mermaid_runtime::HostShell,
929) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
930 if let TurnState::ExecutingTools {
931 calls, outcomes, ..
932 } = turn
933 {
934 let actions: Vec<mermaid_domain::ActionDisplay> = calls
935 .iter()
936 .zip(outcomes)
937 .filter_map(|(call, outcome)| match outcome {
938 Some(outcome) => Some(mermaid_domain::transition::action_display_for_shell(
939 call, outcome, host_shell,
940 )),
941 None => {
942 let name = call.source.function.name.as_str();
943 if name == "agent" || name == "ask_user_question" {
944 return None;
945 }
946 let (action_type, target) =
947 mermaid_domain::display_info_for_shell(call, host_shell);
948 Some(mermaid_domain::ActionDisplay {
949 action_type,
950 target,
951 result: mermaid_domain::ActionResult::Running,
952 details: mermaid_domain::ActionDetails::Simple,
953 duration_seconds: None,
954 metadata: None,
955 })
956 },
957 })
958 .collect();
959 if actions.is_empty() {
960 return std::borrow::Cow::Borrowed(committed);
961 }
962 let mut msg = mermaid_model::models::ChatMessage::assistant("");
963 msg.timestamp = now;
964 msg.actions = actions;
965 let mut out = committed.to_vec();
966 out.push(msg);
967 return std::borrow::Cow::Owned(out);
968 }
969 if let TurnState::Generating {
973 partial_text,
974 partial_reasoning,
975 continuation,
976 ..
977 } = turn
978 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
979 {
980 let thinking = if partial_reasoning.is_empty() {
981 None
982 } else {
983 Some(partial_reasoning.clone())
984 };
985 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
986 let content = if stitching {
987 let prev = &committed[committed.len() - 1].content;
988 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
989 partial_text[skip..].to_string()
990 } else {
991 partial_text.clone()
992 };
993 let msg = mermaid_model::models::ChatMessage {
994 role: mermaid_model::models::MessageRole::Assistant,
995 content,
996 timestamp: now,
999 kind: if stitching {
1000 mermaid_model::models::ChatMessageKind::Continuation
1001 } else {
1002 mermaid_model::models::ChatMessageKind::Normal
1003 },
1004 metadata: None,
1005 actions: Vec::new(),
1006 thinking,
1007 images: None,
1008 image_numbers: None,
1009 tool_calls: None,
1010 tool_call_id: None,
1011 tool_name: None,
1012 provider_continuation: None,
1013 };
1014 let mut out = committed.to_vec();
1015 out.push(msg);
1016 std::borrow::Cow::Owned(out)
1017 } else {
1018 std::borrow::Cow::Borrowed(committed)
1019 }
1020}
1021
1022fn exit_armed(state: &State) -> bool {
1026 state
1027 .ui
1028 .exit_armed_until
1029 .is_some_and(|deadline| state.now <= deadline)
1030}
1031
1032fn active_toast(state: &State) -> Option<String> {
1036 state
1037 .ui
1038 .toast
1039 .as_ref()
1040 .filter(|(_, until)| state.now <= *until)
1041 .map(|(text, _)| text.clone())
1042}
1043
1044fn rewind_armed(state: &State) -> bool {
1047 state
1048 .ui
1049 .esc_armed_at
1050 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1051}
1052
1053fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1058 let now_sys = std::time::SystemTime::from(state.now);
1059 let elapsed_since =
1060 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1061
1062 let mut rows = Vec::new();
1063 let mut running_agents = 0usize;
1064 let mut pending_total = 0usize;
1065 let mut bg_available = false;
1066 if let TurnState::ExecutingTools {
1067 calls,
1068 outcomes,
1069 started,
1070 ..
1071 } = &state.turn
1072 {
1073 let elapsed = elapsed_since(*started);
1074 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1075 pending_total += 1;
1076 let name = call.source.function.name.as_str();
1077 if name == "execute_command" || name == "agent" {
1078 bg_available = true;
1079 }
1080 if name != "agent" {
1081 continue;
1082 }
1083 running_agents += 1;
1084 let (_, description) = mermaid_domain::display_info_for(call);
1085 let live = state.ui.live_tool_status.get(&call.call_id);
1086 rows.push(widgets::AgentPanelRow {
1087 description,
1088 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1089 tokens: live.map_or(0, |l| l.tokens),
1090 elapsed_secs: elapsed,
1091 backgrounded: false,
1092 });
1093 }
1094 }
1095 for agent in &state.runtime.background_agents {
1096 rows.push(widgets::AgentPanelRow {
1097 description: agent.description.clone(),
1098 activity: agent.activity.clone(),
1099 tokens: agent.tokens,
1100 elapsed_secs: elapsed_since(agent.started),
1101 backgrounded: true,
1102 });
1103 }
1104 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1105 if running_agents == 1 {
1106 "Running 1 agent".to_string()
1107 } else {
1108 format!("Running {running_agents} agents")
1109 }
1110 });
1111 (rows, status_override, bg_available)
1112}
1113
1114fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1119 None
1120}
1121
1122#[cfg(test)]
1127pub(crate) fn render_frame(
1128 state: &State,
1129 rstate: &mut RenderCache,
1130 width: u16,
1131 height: u16,
1132) -> String {
1133 use ratatui::Terminal;
1134 use ratatui::backend::TestBackend;
1135 let backend = TestBackend::new(width, height);
1136 let mut terminal = Terminal::new(backend).expect("terminal");
1137 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1138 let buf = terminal.backend().buffer();
1139 let mut out = String::new();
1140 for y in 0..buf.area.height {
1141 for x in 0..buf.area.width {
1142 out.push_str(buf[(x, y)].symbol());
1143 }
1144 out.push('\n');
1145 }
1146 out
1147}
1148
1149#[cfg(test)]
1153mod snapshots;
1154
1155#[cfg(test)]
1157mod bench;
1158
1159#[cfg(test)]
1160mod tests {
1161 use super::*;
1162 use mermaid_domain::Config;
1163 use mermaid_domain::{State, TurnState};
1164 use ratatui::Terminal;
1165 use ratatui::backend::TestBackend;
1166 use std::path::PathBuf;
1167
1168 fn mock_state() -> State {
1169 State::new(
1170 Config::default(),
1171 PathBuf::from("/tmp/p"),
1172 "ollama/test".to_string(),
1173 chrono::Local::now(),
1174 PathBuf::from("/tmp"),
1175 )
1176 }
1177
1178 fn test_cache() -> RenderCache {
1181 RenderCache::new("testhost".to_string(), "testuser".to_string())
1182 }
1183
1184 fn render_to_string(state: &State) -> String {
1185 render_frame(state, &mut test_cache(), 80, 24)
1186 }
1187
1188 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1189 let backend = TestBackend::new(80, 24);
1190 let mut terminal = Terminal::new(backend).expect("terminal");
1191 let mut rstate = test_cache();
1192 terminal
1193 .draw(|f| render(state, &mut rstate, f))
1194 .expect("draw");
1195 terminal.backend().buffer().clone()
1196 }
1197
1198 #[test]
1199 fn theme_choice_changes_colors_never_glyphs() {
1200 let mut state = mock_state();
1204 state
1205 .session
1206 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1207 let dark = render_to_string(&state);
1208 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1209 let light = render_to_string(&state);
1210 assert_eq!(dark, light, "light theme changed glyphs");
1211 state.ui.no_color = true;
1212 let plain = render_to_string(&state);
1213 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1214 }
1215
1216 #[test]
1217 fn theme_memo_swaps_palette_on_state_change() {
1218 let mut state = mock_state();
1219 let mut rstate = test_cache();
1220 render_frame(&state, &mut rstate, 80, 24);
1221 assert_eq!(rstate.theme.name, "Dark");
1222 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1223 render_frame(&state, &mut rstate, 80, 24);
1224 assert_eq!(rstate.theme.name, "Light");
1225 state.ui.no_color = true;
1227 render_frame(&state, &mut rstate, 80, 24);
1228 assert_eq!(rstate.theme.name, "Plain");
1229 }
1230
1231 #[test]
1232 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1233 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1234
1235 let mut state = mock_state();
1236 let call_id = ToolCallId(7);
1237 state.turn = TurnState::ExecutingTools {
1238 id: TurnId(1),
1239 started: std::time::SystemTime::now(),
1240 calls: vec![PendingToolCall {
1241 call_id,
1242 source: mermaid_model::models::tool_call::ToolCall {
1243 id: None,
1244 function: mermaid_model::models::tool_call::FunctionCall {
1245 name: "agent".to_string(),
1246 arguments: serde_json::json!({"description": "explore crates"}),
1247 },
1248 },
1249 }],
1250 outcomes: vec![None],
1251 };
1252 state.ui.live_tool_status.insert(
1253 call_id,
1254 LiveToolStatus {
1255 activity: "read_file…".to_string(),
1256 tokens: 12_300,
1257 },
1258 );
1259
1260 let live = build_live_messages(
1263 &[],
1264 &state.turn,
1265 chrono::Local::now(),
1266 mermaid_runtime::HostShell::Posix,
1267 );
1268 assert!(
1269 live.is_empty(),
1270 "a pending agent call must not synthesize a transcript row"
1271 );
1272 let (rows, override_text, bg_available) = agent_panel_data(&state);
1273 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1274 assert!(bg_available, "agents are detachable via ctrl+b");
1275 assert_eq!(rows.len(), 1);
1276 assert_eq!(rows[0].description, "explore crates");
1277 assert_eq!(rows[0].activity, "read_file…");
1278 assert_eq!(rows[0].tokens, 12_300);
1279 assert!(!rows[0].backgrounded);
1280 }
1281
1282 #[test]
1283 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1284 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1285
1286 let mut state = mock_state();
1287 let exec_id = ToolCallId(8);
1288 let agent_id = ToolCallId(9);
1289 let call = |id, name: &str, args| PendingToolCall {
1290 call_id: id,
1291 source: mermaid_model::models::tool_call::ToolCall {
1292 id: None,
1293 function: mermaid_model::models::tool_call::FunctionCall {
1294 name: name.to_string(),
1295 arguments: args,
1296 },
1297 },
1298 };
1299 state.turn = TurnState::ExecutingTools {
1300 id: TurnId(1),
1301 started: std::time::SystemTime::now(),
1302 calls: vec![
1303 call(
1304 exec_id,
1305 "execute_command",
1306 serde_json::json!({"command": "cargo test"}),
1307 ),
1308 call(
1309 agent_id,
1310 "agent",
1311 serde_json::json!({"description": "audit docs"}),
1312 ),
1313 ],
1314 outcomes: vec![None, None],
1315 };
1316 state.ui.live_tool_status.insert(
1317 exec_id,
1318 LiveToolStatus {
1319 activity: String::new(),
1320 tokens: 0,
1321 },
1322 );
1323
1324 let live = build_live_messages(
1327 &[],
1328 &state.turn,
1329 chrono::Local::now(),
1330 mermaid_runtime::HostShell::Posix,
1331 );
1332 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1333 let actions = &live[0].actions;
1334 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1335 assert_eq!(actions[0].action_type, "Bash");
1336 assert_eq!(actions[0].target, "cargo test");
1337 assert!(matches!(
1338 actions[0].result,
1339 mermaid_domain::ActionResult::Running
1340 ));
1341 let (rows, override_text, _) = agent_panel_data(&state);
1342 assert_eq!(override_text, None);
1343 assert_eq!(rows.len(), 1);
1344 }
1345
1346 #[test]
1347 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1348 use mermaid_domain::{GenPhase, TurnId};
1349 use mermaid_model::models::ChatMessage;
1350 use std::borrow::Cow;
1351 use std::time::SystemTime;
1352
1353 let committed = vec![ChatMessage::user("hi")];
1354 let now = chrono::Local::now();
1355
1356 let idle = build_live_messages(
1358 &committed,
1359 &TurnState::Idle,
1360 now,
1361 mermaid_runtime::HostShell::Posix,
1362 );
1363 assert!(matches!(idle, Cow::Borrowed(_)));
1364 assert_eq!(idle.len(), 1);
1365
1366 let turn = TurnState::Generating {
1369 id: TurnId(1),
1370 started: SystemTime::now(),
1371 partial_text: "draft".to_string(),
1372 partial_reasoning: String::new(),
1373 tokens: 0,
1374 phase: GenPhase::Sending,
1375 provider_continuation: None,
1376 pending_tool_calls: Vec::new(),
1377 continuation: false,
1378 };
1379 let live = build_live_messages(&committed, &turn, now, mermaid_runtime::HostShell::Posix);
1380 assert!(matches!(live, Cow::Owned(_)));
1381 assert_eq!(live.len(), 2);
1382 assert_eq!(live[1].timestamp, now);
1383 }
1384
1385 fn kinded(
1386 mut msg: mermaid_model::models::ChatMessage,
1387 kind: mermaid_model::models::ChatMessageKind,
1388 ) -> mermaid_model::models::ChatMessage {
1389 msg.kind = kind;
1390 msg
1391 }
1392
1393 #[test]
1394 fn stitch_committed_merges_chain_and_hides_nudges() {
1395 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1396 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1397 part1.thinking = Some("first trace".to_string());
1398 let mut part2 = kinded(
1400 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1401 ChatMessageKind::Continuation,
1402 );
1403 part2.thinking = Some("second trace".to_string());
1404 let committed = vec![
1405 ChatMessage::user("audit the widget"),
1406 part1,
1407 kinded(
1408 ChatMessage::system("resume nudge"),
1409 ChatMessageKind::RecoveryNudge,
1410 ),
1411 part2,
1412 ];
1413
1414 assert!(needs_stitch(&committed, &TurnState::Idle));
1415 let stitched = stitch_committed(&committed);
1416 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1417 assert_eq!(
1418 stitched[1].content,
1419 "The audit found three issues in the resolver, and here is the fix.",
1420 "contents merge with the resume echo trimmed"
1421 );
1422 assert_eq!(
1423 stitched[1].thinking.as_deref(),
1424 Some("first trace\n\nsecond trace"),
1425 "both reasoning segments survive in order"
1426 );
1427 assert!(
1428 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1429 "nudges never render"
1430 );
1431 }
1432
1433 #[test]
1436 fn context_markers_are_hidden_from_the_transcript() {
1437 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1438 let committed = vec![
1439 ChatMessage::user("plan this"),
1440 kinded(
1441 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1442 ChatMessageKind::ContextMarker,
1443 ),
1444 ChatMessage::assistant("Grounding first."),
1445 ];
1446 assert!(
1451 !needs_stitch(&committed, &TurnState::Idle),
1452 "a marker alone must not defeat the zero-copy path",
1453 );
1454 let stitched = stitch_committed(&committed);
1456 assert_eq!(stitched.len(), 2, "user + assistant only");
1457 assert!(
1458 !stitched
1459 .iter()
1460 .any(|m| m.content.contains("Plan mode is now ON")),
1461 "markers never render"
1462 );
1463 }
1464
1465 #[test]
1466 fn stitch_refuses_non_bubble_predecessor() {
1467 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1468 let committed = vec![
1472 kinded(
1473 ChatMessage::assistant("checkpoint summary"),
1474 ChatMessageKind::ContextCheckpoint,
1475 ),
1476 kinded(
1477 ChatMessage::assistant("orphaned continuation"),
1478 ChatMessageKind::Continuation,
1479 ),
1480 ];
1481 let stitched = stitch_committed(&committed);
1482 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1483 assert_eq!(stitched[1].content, "orphaned continuation");
1484 }
1485
1486 #[test]
1487 fn needs_stitch_is_false_for_plain_sessions() {
1488 use mermaid_model::models::ChatMessage;
1489 let committed = vec![
1492 ChatMessage::user("hi"),
1493 ChatMessage::assistant("hello"),
1494 ChatMessage::system("note"),
1495 ];
1496 assert!(!needs_stitch(&committed, &TurnState::Idle));
1497 }
1498
1499 #[test]
1507 fn a_live_continuation_still_forces_the_stitch() {
1508 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1509 let committed = vec![
1510 ChatMessage::user("write it"),
1511 ChatMessage::assistant("first half"),
1512 kinded(
1513 ChatMessage::system("output limit — continuing"),
1514 ChatMessageKind::RecoveryNudge,
1515 ),
1516 ];
1517 let streaming = TurnState::Generating {
1518 id: mermaid_domain::TurnId(1),
1519 started: std::time::SystemTime::UNIX_EPOCH,
1520 partial_text: "first half and the rest".to_string(),
1521 partial_reasoning: String::new(),
1522 tokens: 0,
1523 phase: mermaid_domain::GenPhase::Streaming,
1524 provider_continuation: None,
1525 pending_tool_calls: Vec::new(),
1526 continuation: true,
1527 };
1528 assert!(
1529 needs_stitch(&committed, &streaming),
1530 "a live continuation needs the nudge stripped to find its bubble",
1531 );
1532 let stitched = stitch_committed(&committed);
1534 assert!(
1535 stitched.last().is_some_and(mergeable_into),
1536 "the stitched tail is the assistant bubble the partial merges into",
1537 );
1538 }
1539
1540 #[test]
1541 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1542 use mermaid_domain::{GenPhase, TurnId};
1543 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1544
1545 let committed = vec![ChatMessage::assistant(
1546 "the fix lands in the resolver module",
1547 )];
1548 let turn = TurnState::Generating {
1549 id: TurnId(2),
1550 started: std::time::SystemTime::now(),
1551 partial_text: "in the resolver module, specifically the clamp".to_string(),
1552 partial_reasoning: String::new(),
1553 tokens: 0,
1554 phase: GenPhase::Streaming,
1555 provider_continuation: None,
1556 pending_tool_calls: Vec::new(),
1557 continuation: true,
1558 };
1559 let live = build_live_messages(
1560 &committed,
1561 &turn,
1562 chrono::Local::now(),
1563 mermaid_runtime::HostShell::Posix,
1564 );
1565 let streamed = live.last().expect("pseudo-message appended");
1566 assert_eq!(
1567 streamed.kind,
1568 ChatMessageKind::Continuation,
1569 "the live half is stamped so the widget draws it prefix-less"
1570 );
1571 assert_eq!(
1572 streamed.content, ", specifically the clamp",
1573 "the leading resume echo is trimmed against the committed tail"
1574 );
1575 }
1576
1577 #[test]
1578 fn auto_continued_reply_renders_as_one_bubble() {
1579 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1580 let mut s = mock_state();
1581 s.session.append(ChatMessage::user("audit"), s.now);
1582 s.session
1583 .append(ChatMessage::assistant("part one of the reply"), s.now);
1584 s.session.append(
1585 kinded(
1586 ChatMessage::system("output limit — continuing"),
1587 ChatMessageKind::RecoveryNudge,
1588 ),
1589 s.now,
1590 );
1591 s.session.append(
1592 kinded(
1593 ChatMessage::assistant("and part two lands here"),
1594 ChatMessageKind::Continuation,
1595 ),
1596 s.now,
1597 );
1598
1599 let out = render_to_string(&s);
1600 assert!(out.contains("part one of the reply"));
1601 assert!(out.contains("and part two lands here"));
1602 assert!(
1603 !out.contains("continuing"),
1604 "the recovery nudge never renders"
1605 );
1606 assert_eq!(
1607 out.matches('●').count(),
1608 1,
1609 "both halves share one assistant bullet:\n{out}"
1610 );
1611 }
1612
1613 #[test]
1614 fn streaming_continuation_renders_without_fresh_bullet() {
1615 use mermaid_domain::{GenPhase, TurnId};
1616 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1617 let mut s = mock_state();
1618 s.session.append(ChatMessage::user("audit"), s.now);
1619 s.session
1620 .append(ChatMessage::assistant("part one of the reply"), s.now);
1621 s.session.append(
1622 kinded(
1623 ChatMessage::system("output limit — continuing"),
1624 ChatMessageKind::RecoveryNudge,
1625 ),
1626 s.now,
1627 );
1628 s.turn = TurnState::Generating {
1629 id: TurnId(3),
1630 started: std::time::SystemTime::now(),
1631 partial_text: "and part two streams in".to_string(),
1632 partial_reasoning: String::new(),
1633 tokens: 0,
1634 phase: GenPhase::Streaming,
1635 provider_continuation: None,
1636 pending_tool_calls: Vec::new(),
1637 continuation: true,
1638 };
1639
1640 let out = render_to_string(&s);
1641 assert!(out.contains("part one of the reply"));
1642 assert!(out.contains("and part two streams in"));
1643 assert!(!out.contains("continuing"), "live nudge hidden too");
1644 assert_eq!(
1645 out.matches('●').count(),
1646 1,
1647 "the streaming half joins the committed bubble:\n{out}"
1648 );
1649 }
1650
1651 #[test]
1652 fn user_prompt_renders_with_highlight_band() {
1653 let mut s = mock_state();
1654 s.session.append(
1655 mermaid_model::models::ChatMessage::user("hello there"),
1656 s.now,
1657 );
1658 let buf = render_to_buffer(&s);
1659 let band_bg = crate::render::theme::Theme::dark()
1660 .colors
1661 .user_message_background
1662 .to_color();
1663 let y = (0..buf.area.height)
1665 .find(|&y| {
1666 (0..buf.area.width)
1667 .map(|x| buf[(x, y)].symbol())
1668 .collect::<String>()
1669 .contains("hello there")
1670 })
1671 .expect("user prompt should render");
1672 let banded = (0..buf.area.width)
1675 .filter(|&x| buf[(x, y)].bg == band_bg)
1676 .count();
1677 assert!(
1678 banded >= (buf.area.width as usize) * 3 / 4,
1679 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1680 buf.area.width
1681 );
1682 }
1683
1684 #[test]
1685 fn idle_state_renders_cwd_and_model_footer() {
1686 let s = mock_state();
1687 let frame = render_to_string(&s);
1688 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1690 assert!(frame.contains("ollama/test"));
1691 }
1692
1693 #[test]
1694 fn status_line_appears_during_generating() {
1695 let mut s = mock_state();
1696 s.turn = mermaid_domain::transition::start_generating(
1697 mermaid_domain::TurnId(1),
1698 std::time::SystemTime::now(),
1699 );
1700 let frame = render_to_string(&s);
1701 assert!(
1702 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1703 "expected generation status in frame"
1704 );
1705 }
1706
1707 #[test]
1708 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1709 use mermaid_domain::PendingToolCall;
1710 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1711 let mut s = mock_state();
1712 let call = PendingToolCall {
1713 call_id: mermaid_domain::ToolCallId(1),
1714 source: ModelToolCall {
1715 id: Some("c1".to_string()),
1716 function: FunctionCall {
1717 name: "execute_command".to_string(),
1718 arguments: serde_json::json!({"command": "npm run dev"}),
1719 },
1720 },
1721 };
1722 s.turn = TurnState::ExecutingTools {
1723 id: mermaid_domain::TurnId(1),
1724 started: std::time::SystemTime::now(),
1725 calls: vec![call],
1726 outcomes: vec![None],
1727 };
1728 let frame = render_to_string(&s);
1729 assert!(frame.contains("Running tools..."), "got: {frame}");
1732 assert!(
1733 !frame.contains("Running tools:"),
1734 "status line must not carry tool detail; got: {frame}"
1735 );
1736 assert!(
1738 frame.contains("npm run dev"),
1739 "transcript must show the in-flight call's action row; got: {frame}"
1740 );
1741 }
1742
1743 #[test]
1744 fn pending_question_and_agent_calls_get_no_transcript_row() {
1745 use mermaid_domain::PendingToolCall;
1746 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1747 let mut s = mock_state();
1748 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1749 call_id: mermaid_domain::ToolCallId(id),
1750 source: ModelToolCall {
1751 id: Some(format!("c{id}")),
1752 function: FunctionCall {
1753 name: name.to_string(),
1754 arguments: args,
1755 },
1756 },
1757 };
1758 s.turn = TurnState::ExecutingTools {
1759 id: mermaid_domain::TurnId(1),
1760 started: std::time::SystemTime::now(),
1761 calls: vec![
1762 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1763 mk(
1764 2,
1765 "agent",
1766 serde_json::json!({"description": "scan the repo"}),
1767 ),
1768 ],
1769 outcomes: vec![None, None],
1770 };
1771 let frame = render_to_string(&s);
1772 assert!(
1775 !frame.contains("ask_user_question"),
1776 "pending question must not surface as a transcript row or status text; got: {frame}"
1777 );
1778 }
1779
1780 #[test]
1781 fn status_line_appears_during_tool_execution_and_shows_queue() {
1782 let mut s = mock_state();
1783 s.turn = TurnState::ExecutingTools {
1784 id: mermaid_domain::TurnId(1),
1785 started: std::time::SystemTime::now(),
1786 calls: Vec::new(),
1787 outcomes: Vec::new(),
1788 };
1789 s.ui.queued_messages
1790 .push_back(mermaid_domain::QueuedMessage {
1791 text: "please steer this".to_string(),
1792 attachment_ids: Vec::new(),
1793 });
1794 let frame = render_to_string(&s);
1795 assert!(frame.contains("Running tools"), "expected tool status");
1796 assert!(
1797 frame.contains("please steer this"),
1798 "queued busy input must be visible"
1799 );
1800 }
1801
1802 #[test]
1803 fn reasoning_blocks_are_collapsed_by_default() {
1804 let mut s = mock_state();
1805 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1806 first_msg.thinking = Some("first private chain of thought".to_string());
1807 s.session.append(first_msg, s.now);
1808 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1809 second_msg.thinking = Some("second private chain of thought".to_string());
1810 s.session.append(second_msg, s.now);
1811 let frame = render_to_string(&s);
1812 assert!(!frame.contains("Reasoning hidden"));
1814 assert!(frame.contains("first visible answer"));
1815 assert!(frame.contains("second visible answer"));
1816 assert!(!frame.contains("first private chain of thought"));
1817 assert!(!frame.contains("second private chain of thought"));
1818 }
1819
1820 #[test]
1824 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1825 let mut s = mock_state();
1826 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1827 msg.thinking = Some("private chain of thought".to_string());
1828 msg.actions.push(mermaid_domain::ActionDisplay {
1829 action_type: "Bash".to_string(),
1830 target: "dir".to_string(),
1831 result: mermaid_domain::ActionResult::Success {
1832 output: "ok".to_string(),
1833 images: None,
1834 },
1835 details: mermaid_domain::ActionDetails::Simple,
1836 duration_seconds: Some(0.015),
1837 metadata: None,
1838 });
1839 s.session.append(msg, s.now);
1840 let frame = render_to_string(&s);
1841 assert!(
1842 !frame.contains("Reasoning hidden"),
1843 "no reasoning-hidden placeholder"
1844 );
1845 assert!(
1846 frame.contains("Bash"),
1847 "the action still renders even though reasoning is hidden"
1848 );
1849 }
1850
1851 #[test]
1852 fn committed_message_appears_in_chat_pane() {
1853 let mut s = mock_state();
1854 s.session.append(
1855 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1856 s.now,
1857 );
1858 let frame = render_to_string(&s);
1859 assert!(frame.contains("unique-user-token-xyz"));
1860 }
1861
1862 #[test]
1863 fn palette_renders_when_input_starts_with_slash() {
1864 let mut s = mock_state();
1865 s.ui.input_buffer = "/help".to_string();
1866 s.ui.input_cursor = 5;
1867 let frame = render_to_string(&s);
1868 assert!(frame.contains("help"));
1870 }
1871
1872 #[test]
1873 fn status_line_helper_maps_idle_to_idle() {
1874 assert_eq!(
1875 GenerationStatus::from_turn(&TurnState::Idle),
1876 GenerationStatus::Idle
1877 );
1878 }
1879
1880 #[test]
1892 fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
1893 use ratatui::Terminal;
1894 use ratatui::backend::TestBackend;
1895
1896 let text = "Create a language that. Your goal is up to you.";
1897 for width in [24u16, 30, 40, 55] {
1898 for n in 0..=text.len() {
1899 let Some(prefix) = text.get(..n) else {
1902 continue;
1903 };
1904 let mut state = mock_state();
1905 state.ui.input_buffer = prefix.to_string();
1906 state.ui.input_cursor = prefix.len();
1907
1908 let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
1909 let mut rstate = test_cache();
1910 terminal
1911 .draw(|f| render(&state, &mut rstate, f))
1912 .expect("draw");
1913 let cursor = terminal.get_cursor_position().expect("cursor");
1914 let buf = terminal.backend().buffer().clone();
1915
1916 let row_at = |y: u16| {
1917 (0..buf.area.width)
1918 .map(|x| buf[(x, y)].symbol())
1919 .collect::<String>()
1920 };
1921 let caret_row = row_at(cursor.y);
1922
1923 assert!(
1926 !caret_row.contains('─'),
1927 "caret on the box border at width {width}, prefix {prefix:?}\n\
1928 row {}: |{caret_row}|",
1929 cursor.y
1930 );
1931
1932 let content_width = width.saturating_sub(2) as usize;
1934 let rows = widgets::rendered_row_count(prefix, content_width);
1935 assert!(
1936 rows <= 5,
1937 "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
1938 );
1939 if let Some(last) = prefix.split_whitespace().next_back() {
1940 let frame: String = (0..buf.area.height)
1941 .map(row_at)
1942 .collect::<Vec<_>>()
1943 .join("\n");
1944 assert!(
1945 frame.contains(last),
1946 "last word {last:?} clipped at width {width}, \
1947 prefix {prefix:?}\n{frame}"
1948 );
1949 }
1950 }
1951 }
1952 }
1953}