1pub mod markdown;
18pub mod theme;
19pub mod widgets;
20pub mod wrap;
21
22use ratatui::{
23 Frame,
24 layout::{Margin, Rect},
25 style::Style,
26 text::{Line, Span},
27};
28use rustc_hash::FxHashMap;
29
30use mermaid_domain::{State, TurnState};
31use mermaid_model::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
32
33use widgets::{
34 ChatState, ChatWidget, GenerationStatus, InputState, InputWidget, SlashPaletteWidget,
35 StatusWidget, build_status_lines,
36};
37
38pub struct RenderCache {
47 pub chat: ChatState,
48 pub host_shell: mermaid_model::safety::HostShell,
53 pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
57 stitched: Option<StitchedMemo>,
63 pub theme: theme::Theme,
64 applied_theme: Option<(mermaid_domain::ThemeChoice, bool)>,
70 pub 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_model::safety::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 pane = bottom_pane(state);
330 let bottom_height = match &pane {
331 BottomPane::Approval => {
332 let body_lines = state
334 .pending_approval
335 .front()
336 .map_or(1, |item| item.prompt.lines().count())
337 .clamp(1, 6) as u16;
338 2 + body_lines + 1 + 3
339 },
340 BottomPane::Question => state.pending_question.front().map_or(2, |qset| {
341 widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
342 }),
343 BottomPane::Confirm => 6,
344 BottomPane::ConversationList | BottomPane::Rewind => 12,
345 BottomPane::PlanConfig => widgets::PLAN_CONFIG_HEIGHT,
346 BottomPane::ModelPicker => widgets::MODEL_PICKER_HEIGHT,
347 BottomPane::FilePicker => {
348 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
349 (rows as u16) + 2
350 },
351 BottomPane::Palette(entries) => (entries.len().clamp(1, 8) as u16) + 2,
352 BottomPane::Status => 2,
353 };
354
355 use ratatui::layout::{Constraint, Direction, Layout};
369 let chunks = Layout::default()
370 .direction(Direction::Vertical)
371 .constraints([
372 Constraint::Fill(1),
373 Constraint::Length(status_line_height),
374 Constraint::Length(tasks_zone_height),
375 Constraint::Length(input_height),
376 Constraint::Length(bottom_height),
377 ])
378 .split(frame.area());
379
380 let chat_area = chunks[0].inner(Margin {
382 horizontal: 1,
383 vertical: 0,
384 });
385 let toast = active_toast(state);
390 let (chat_area, toast_area) = match toast {
391 Some(_) if chat_area.height > 1 => (
392 Rect {
393 height: chat_area.height - 1,
394 ..chat_area
395 },
396 Some(Rect {
397 y: chat_area.y + chat_area.height - 1,
398 height: 1,
399 ..chat_area
400 }),
401 ),
402 _ => (chat_area, None),
403 };
404 let committed = state.session.messages();
409 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
410 let key = stitch_fingerprint(committed);
411 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
412 rstate.stitched = Some(StitchedMemo {
413 key,
414 messages: stitch_committed(committed),
415 });
416 }
417 &rstate
418 .stitched
419 .as_ref()
420 .expect("stitched memo populated above")
421 .messages
422 } else {
423 committed
424 };
425 let live_messages = build_live_messages(base, &state.turn, state.now, rstate.host_shell);
426 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
429 let chat_widget = ChatWidget {
430 messages: live_messages.as_ref(),
431 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
432 theme: &rstate.theme,
433 wrapped_line_cache: &mut rstate.wrapped_line_cache,
434 show_reasoning: state.ui.show_reasoning,
435 blink_on,
436 today: state.now.date_naive(),
437 };
438 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
439
440 if let (Some(text), Some(area)) = (toast, toast_area) {
443 frame.render_widget(
444 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
445 text,
446 Style::new().fg(rstate.theme.colors.info.to_color()),
447 )))
448 .alignment(ratatui::layout::Alignment::Right),
449 area,
450 );
451 }
452
453 if !status_lines.is_empty() {
456 let status_area = chunks[1].inner(Margin {
457 horizontal: 1,
458 vertical: 0,
459 });
460 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
461 }
462
463 if tasks_zone_height > 0 {
465 let tasks_area = chunks[2].inner(Margin {
466 horizontal: 1,
467 vertical: 0,
468 });
469 let lines = widgets::build_task_lines(
470 tasks_store,
471 state.ui.tasks_collapsed,
472 tasks_attached,
473 tasks_area.width,
474 &rstate.theme,
475 );
476 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
477 }
478
479 if !question_modal_open {
483 let input_widget = InputWidget {
484 input: state.ui.input_buffer.as_str(),
485 showing_command_hints: state.ui.input_buffer.starts_with('/'),
486 theme: &rstate.theme,
487 reasoning_active: state.session.reasoning != ReasoningLevel::None,
488 exit_armed: exit_armed(state),
489 rewind_armed: rewind_armed(state),
490 };
491 let mut input_widget_state = InputState {
492 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
493 };
494 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
495
496 let input_area = chunks[3];
498 let content_width = input_area.width.saturating_sub(2) as usize;
499 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
500 &state.ui.input_buffer,
501 state.ui.input_cursor.min(state.ui.input_buffer.len()),
502 content_width,
503 );
504 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
505 }
506
507 let requested = state.session.reasoning;
511 let effective = match supported_reasoning_for(state) {
512 Some(ReasoningCapability::Levels(supp)) => {
513 nearest_effort(requested, &supp).unwrap_or(requested)
514 },
515 _ => requested,
516 };
517 let requested_level = if effective == requested {
518 None
519 } else {
520 Some(requested)
521 };
522
523 match pane {
525 BottomPane::Approval => {
526 if let Some(item) = state.pending_approval.front() {
527 use widgets::ApprovalModalWidget;
528 let options = if item.allowlist_scope.is_empty() {
533 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
534 } else {
535 vec![
536 "1. Yes".to_string(),
537 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
538 "3. No (Esc)".to_string(),
539 ]
540 };
541 let widget = ApprovalModalWidget {
542 theme: &rstate.theme,
543 title: format!("Approval required — {} [{}]", item.tool, item.risk),
544 body: item.prompt.as_str(),
545 options,
546 selected_index: Some(item.selected_option),
547 accent: rstate.theme.colors.warning.to_color(),
548 };
549 frame.render_widget(widget, chunks[4]);
550 }
551 },
552 BottomPane::Question => {
553 if let Some(qset) = state.pending_question.front() {
554 use widgets::QuestionModalWidget;
555 let widget = QuestionModalWidget {
556 theme: &rstate.theme,
557 set: qset,
558 width: chunks[4].width,
559 };
560 frame.render_widget(widget, chunks[4]);
561 }
562 },
563 BottomPane::Confirm => {
564 if let Some(confirm) = &state.confirm {
565 use widgets::ApprovalModalWidget;
566 let widget = ApprovalModalWidget {
567 theme: &rstate.theme,
568 title: "Confirm".to_string(),
569 body: confirm.prompt.as_str(),
570 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
571 selected_index: None,
572 accent: rstate.theme.colors.warning.to_color(),
573 };
574 frame.render_widget(widget, chunks[4]);
575 }
576 },
577 BottomPane::ModelPicker => {
578 if let mermaid_domain::UiMode::ModelPicker {
579 candidates,
580 query,
581 cursor,
582 loading,
583 } = &state.ui.mode
584 {
585 use widgets::ModelPickerWidget;
586 let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
587 let widget = ModelPickerWidget {
588 theme: &rstate.theme,
589 matches: &matches,
590 query,
591 cursor: *cursor,
592 loading: *loading,
593 current: &state.session.model_id,
594 };
595 frame.render_widget(widget, chunks[4]);
596 }
597 },
598 BottomPane::ConversationList => {
599 if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode
600 {
601 use widgets::ConversationListWidget;
602 let widget = ConversationListWidget {
603 theme: &rstate.theme,
604 candidates,
605 cursor: *cursor,
606 };
607 frame.render_widget(widget, chunks[4]);
608 }
609 },
610 BottomPane::Rewind => {
611 if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
612 use widgets::RewindPickerWidget;
613 let widget = RewindPickerWidget {
614 theme: &rstate.theme,
615 candidates,
616 cursor: *cursor,
617 };
618 frame.render_widget(widget, chunks[4]);
619 }
620 },
621 BottomPane::PlanConfig => {
622 if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
623 use widgets::PlanConfigWidget;
624 let widget = PlanConfigWidget {
625 theme: &rstate.theme,
626 plan: &state.settings.plan,
627 session_model: &state.session.model_id,
628 cursor: *cursor,
629 };
630 frame.render_widget(widget, chunks[4]);
631 }
632 },
633 BottomPane::FilePicker => {
634 use widgets::FilePickerWidget;
635 let widget = FilePickerWidget {
636 theme: &rstate.theme,
637 matches: &state.ui.file_picker_matches,
638 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
639 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
640 };
641 frame.render_widget(widget, chunks[4]);
642 },
643 BottomPane::Palette(entries) => {
644 let palette_widget = SlashPaletteWidget {
645 theme: &rstate.theme,
646 entries,
647 selected_index: state.ui.palette_cursor.unwrap_or(0),
648 };
649 frame.render_widget(palette_widget, chunks[4]);
650 },
651 BottomPane::Status => {
652 let cwd = state.cwd.display().to_string();
653 let status_widget = StatusWidget {
654 theme: &rstate.theme,
655 working_dir: &cwd,
656 hostname: &rstate.hostname,
657 username: &rstate.username,
658 version: &rstate.version,
659 context_usage: state.session.context_usage.as_ref(),
660 model_name: &state.session.model_id,
661 reasoning_level: effective,
662 requested_level,
663 safety_mode: state.session.safety_mode,
666 };
667 frame.render_widget(status_widget, chunks[4]);
668 },
669 }
670}
671
672enum BottomPane<'a> {
682 Approval,
683 Question,
684 Confirm,
685 ModelPicker,
686 ConversationList,
687 Rewind,
688 PlanConfig,
689 FilePicker,
690 Palette(Vec<mermaid_domain::slash_commands::PaletteEntry<'a>>),
691 Status,
692}
693
694fn bottom_pane(state: &mermaid_domain::State) -> BottomPane<'_> {
695 use mermaid_domain::{Focus, UiMode};
696 match state.focus() {
697 Focus::ApprovalModal => BottomPane::Approval,
698 Focus::QuestionModal => BottomPane::Question,
699 Focus::ConfirmModal => BottomPane::Confirm,
700 Focus::Picker => match state.ui.mode {
701 UiMode::ModelPicker { .. } => BottomPane::ModelPicker,
702 UiMode::ConversationList { .. } => BottomPane::ConversationList,
703 UiMode::RewindPicker { .. } => BottomPane::Rewind,
704 UiMode::PlanConfig { .. } => BottomPane::PlanConfig,
705 UiMode::EditingInput | UiMode::ModelList => BottomPane::Status,
707 },
708 Focus::Composer => {
709 if state.ui.file_picker_open() {
710 BottomPane::FilePicker
711 } else if state.ui.input_buffer.starts_with('/') {
712 let typed = state
713 .ui
714 .input_buffer
715 .trim_start_matches('/')
716 .split_whitespace()
717 .next()
718 .unwrap_or("");
719 BottomPane::Palette(mermaid_domain::slash_commands::filter_entries(
720 typed,
721 &state.plugin_commands,
722 ))
723 } else {
724 BottomPane::Status
725 }
726 },
727 }
728}
729
730pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
737 prev.role == mermaid_model::models::MessageRole::Assistant
738 && matches!(
739 prev.kind,
740 mermaid_model::models::ChatMessageKind::Normal
741 | mermaid_model::models::ChatMessageKind::Continuation
742 )
743 && prev.tool_calls.is_none()
744}
745
746fn chat_content_key(
766 state: &State,
767 base: &[mermaid_model::models::ChatMessage],
768 live: &[mermaid_model::models::ChatMessage],
769 blink_on: bool,
770) -> u64 {
771 use std::hash::{Hash, Hasher};
772 let mut h = rustc_hash::FxHasher::default();
773 state.session.conversation.revision().hash(&mut h);
774 base.len().hash(&mut h);
777 for msg in live.iter().skip(base.len()) {
778 msg.content.hash(&mut h);
779 msg.thinking.hash(&mut h);
780 std::mem::discriminant(&msg.kind).hash(&mut h);
781 msg.actions.len().hash(&mut h);
782 for action in &msg.actions {
783 action.action_type.hash(&mut h);
784 action.target.hash(&mut h);
785 std::mem::discriminant(&action.result).hash(&mut h);
786 }
787 }
788 if !matches!(state.turn, TurnState::Idle) {
789 blink_on.hash(&mut h);
790 }
791 h.finish()
792}
793
794fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
815 let live_continuation = matches!(
816 turn,
817 TurnState::Generating { continuation, .. } if *continuation
818 );
819 live_continuation
820 || committed
821 .iter()
822 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
823}
824
825fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
831 use std::hash::{Hash, Hasher};
832 use std::mem::discriminant;
833
834 let mut h = rustc_hash::FxHasher::default();
835 committed.len().hash(&mut h);
836 for msg in committed {
837 msg.content.hash(&mut h);
838 msg.thinking.hash(&mut h);
839 msg.timestamp.timestamp().hash(&mut h);
840 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
841 msg.image_numbers
842 .as_ref()
843 .map_or(0, |v| v.len())
844 .hash(&mut h);
845 discriminant(&msg.role).hash(&mut h);
846 discriminant(&msg.kind).hash(&mut h);
847 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
848 msg.actions.len().hash(&mut h);
856 for action in &msg.actions {
857 action.action_type.hash(&mut h);
858 action.target.hash(&mut h);
859 discriminant(&action.result).hash(&mut h);
860 discriminant(&action.details).hash(&mut h);
861 action.duration_seconds.map(f64::to_bits).hash(&mut h);
862 if let Some(meta) = &action.metadata {
863 meta.lines_added.hash(&mut h);
864 meta.lines_removed.hash(&mut h);
865 meta.diff_truncated.hash(&mut h);
866 meta.display_diff.as_ref().map(String::len).hash(&mut h);
867 }
868 }
869 }
870 h.finish()
871}
872
873fn stitch_committed(
885 committed: &[mermaid_model::models::ChatMessage],
886) -> Vec<mermaid_model::models::ChatMessage> {
887 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
888 for msg in committed {
889 if matches!(
890 msg.kind,
891 mermaid_model::models::ChatMessageKind::RecoveryNudge
892 | mermaid_model::models::ChatMessageKind::ContextMarker
893 ) {
894 continue;
895 }
896 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
897 && let Some(prev) = out.last_mut()
898 && mergeable_into(prev)
899 {
900 merge_continuation(prev, msg);
901 continue;
902 }
903 out.push(msg.clone());
904 }
905 out
906}
907
908fn merge_continuation(
912 prev: &mut mermaid_model::models::ChatMessage,
913 cont: &mermaid_model::models::ChatMessage,
914) {
915 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
916 prev.content.push_str(&cont.content[skip..]);
917 if let Some(cont_thinking) = &cont.thinking {
918 match &mut prev.thinking {
919 Some(t) => {
920 t.push_str("\n\n");
921 t.push_str(cont_thinking);
922 },
923 None => prev.thinking = Some(cont_thinking.clone()),
924 }
925 }
926 prev.actions.extend(cont.actions.iter().cloned());
927 if let Some(imgs) = &cont.images {
928 prev.images
929 .get_or_insert_with(Vec::new)
930 .extend(imgs.iter().cloned());
931 }
932 if let Some(nums) = &cont.image_numbers {
933 prev.image_numbers
934 .get_or_insert_with(Vec::new)
935 .extend(nums.iter().copied());
936 }
937 if cont.tool_calls.is_some() {
940 prev.tool_calls = cont.tool_calls.clone();
941 }
942}
943
944fn build_live_messages<'a>(
964 committed: &'a [mermaid_model::models::ChatMessage],
965 turn: &TurnState,
966 now: chrono::DateTime<chrono::Local>,
967 host_shell: mermaid_model::safety::HostShell,
968) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
969 if let TurnState::ExecutingTools {
970 calls, outcomes, ..
971 } = turn
972 {
973 let actions: Vec<mermaid_domain::ActionDisplay> = calls
974 .iter()
975 .zip(outcomes)
976 .filter_map(|(call, outcome)| match outcome {
977 Some(outcome) => Some(mermaid_domain::action_display::action_display_for_shell(
978 call, outcome, host_shell,
979 )),
980 None => {
981 let name = call.source.function.name.as_str();
982 if name == "agent" || name == "ask_user_question" {
983 return None;
984 }
985 let (action_type, target) =
986 mermaid_domain::display_info_for_shell(call, host_shell);
987 Some(mermaid_domain::ActionDisplay {
988 action_type,
989 target,
990 result: mermaid_domain::ActionResult::Running,
991 details: mermaid_domain::ActionDetails::Simple,
992 duration_seconds: None,
993 metadata: None,
994 })
995 },
996 })
997 .collect();
998 if actions.is_empty() {
999 return std::borrow::Cow::Borrowed(committed);
1000 }
1001 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1002 msg.timestamp = now;
1003 msg.actions = actions;
1004 let mut out = committed.to_vec();
1005 out.push(msg);
1006 return std::borrow::Cow::Owned(out);
1007 }
1008 if let TurnState::Generating {
1012 partial_text,
1013 partial_reasoning,
1014 continuation,
1015 ..
1016 } = turn
1017 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
1018 {
1019 let thinking = if partial_reasoning.is_empty() {
1020 None
1021 } else {
1022 Some(partial_reasoning.clone())
1023 };
1024 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
1025 let content = if stitching {
1026 let prev = &committed[committed.len() - 1].content;
1027 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
1028 partial_text[skip..].to_string()
1029 } else {
1030 partial_text.clone()
1031 };
1032 let msg = mermaid_model::models::ChatMessage {
1033 role: mermaid_model::models::MessageRole::Assistant,
1034 content,
1035 timestamp: now,
1038 kind: if stitching {
1039 mermaid_model::models::ChatMessageKind::Continuation
1040 } else {
1041 mermaid_model::models::ChatMessageKind::Normal
1042 },
1043 metadata: None,
1044 actions: Vec::new(),
1045 thinking,
1046 images: None,
1047 image_numbers: None,
1048 tool_calls: None,
1049 tool_call_id: None,
1050 tool_name: None,
1051 provider_continuation: None,
1052 };
1053 let mut out = committed.to_vec();
1054 out.push(msg);
1055 std::borrow::Cow::Owned(out)
1056 } else {
1057 std::borrow::Cow::Borrowed(committed)
1058 }
1059}
1060
1061fn exit_armed(state: &State) -> bool {
1065 state
1066 .ui
1067 .exit_armed_until
1068 .is_some_and(|deadline| state.now <= deadline)
1069}
1070
1071fn active_toast(state: &State) -> Option<String> {
1075 state
1076 .ui
1077 .toast
1078 .as_ref()
1079 .filter(|(_, until)| state.now <= *until)
1080 .map(|(text, _)| text.clone())
1081}
1082
1083fn rewind_armed(state: &State) -> bool {
1086 state
1087 .ui
1088 .esc_armed_at
1089 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1090}
1091
1092fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1097 let now_sys = std::time::SystemTime::from(state.now);
1098 let elapsed_since =
1099 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1100
1101 let mut rows = Vec::new();
1102 let mut running_agents = 0usize;
1103 let mut pending_total = 0usize;
1104 let mut bg_available = false;
1105 if let TurnState::ExecutingTools {
1106 calls,
1107 outcomes,
1108 started,
1109 ..
1110 } = &state.turn
1111 {
1112 let elapsed = elapsed_since(*started);
1113 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1114 pending_total += 1;
1115 let name = call.source.function.name.as_str();
1116 if name == "execute_command" || name == "agent" {
1117 bg_available = true;
1118 }
1119 if name != "agent" {
1120 continue;
1121 }
1122 running_agents += 1;
1123 let (_, description) = mermaid_domain::display_info_for(call);
1124 let live = state.ui.live_tool_status.get(&call.call_id);
1125 rows.push(widgets::AgentPanelRow {
1126 description,
1127 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1128 tokens: live.map_or(0, |l| l.tokens),
1129 elapsed_secs: elapsed,
1130 backgrounded: false,
1131 });
1132 }
1133 }
1134 for agent in &state.runtime.background_agents {
1135 rows.push(widgets::AgentPanelRow {
1136 description: agent.description.clone(),
1137 activity: agent.activity.clone(),
1138 tokens: agent.tokens,
1139 elapsed_secs: elapsed_since(agent.started),
1140 backgrounded: true,
1141 });
1142 }
1143 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1144 if running_agents == 1 {
1145 "Running 1 agent".to_string()
1146 } else {
1147 format!("Running {running_agents} agents")
1148 }
1149 });
1150 (rows, status_override, bg_available)
1151}
1152
1153fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1158 None
1159}
1160
1161#[cfg(test)]
1166pub(crate) fn render_frame(
1167 state: &State,
1168 rstate: &mut RenderCache,
1169 width: u16,
1170 height: u16,
1171) -> String {
1172 use ratatui::Terminal;
1173 use ratatui::backend::TestBackend;
1174 let backend = TestBackend::new(width, height);
1175 let mut terminal = Terminal::new(backend).expect("terminal");
1176 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1177 let buf = terminal.backend().buffer();
1178 let mut out = String::new();
1179 for y in 0..buf.area.height {
1180 for x in 0..buf.area.width {
1181 out.push_str(buf[(x, y)].symbol());
1182 }
1183 out.push('\n');
1184 }
1185 out
1186}
1187
1188#[cfg(test)]
1192mod snapshots;
1193
1194#[cfg(test)]
1196mod bench;
1197
1198#[cfg(test)]
1199mod tests {
1200 use super::*;
1201 use mermaid_domain::Config;
1202 use mermaid_domain::{State, TurnState};
1203 use ratatui::Terminal;
1204 use ratatui::backend::TestBackend;
1205 use std::path::PathBuf;
1206
1207 fn mock_state() -> State {
1208 State::new(
1209 Config::default(),
1210 PathBuf::from("/tmp/p"),
1211 "ollama/test".to_string(),
1212 chrono::Local::now(),
1213 PathBuf::from("/tmp"),
1214 )
1215 }
1216
1217 fn test_cache() -> RenderCache {
1220 RenderCache::new("testhost".to_string(), "testuser".to_string())
1221 }
1222
1223 fn render_to_string(state: &State) -> String {
1224 render_frame(state, &mut test_cache(), 80, 24)
1225 }
1226
1227 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1228 let backend = TestBackend::new(80, 24);
1229 let mut terminal = Terminal::new(backend).expect("terminal");
1230 let mut rstate = test_cache();
1231 terminal
1232 .draw(|f| render(state, &mut rstate, f))
1233 .expect("draw");
1234 terminal.backend().buffer().clone()
1235 }
1236
1237 #[test]
1238 fn theme_choice_changes_colors_never_glyphs() {
1239 let mut state = mock_state();
1243 state
1244 .session
1245 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1246 let dark = render_to_string(&state);
1247 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1248 let light = render_to_string(&state);
1249 assert_eq!(dark, light, "light theme changed glyphs");
1250 state.ui.no_color = true;
1251 let plain = render_to_string(&state);
1252 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1253 }
1254
1255 #[test]
1256 fn theme_memo_swaps_palette_on_state_change() {
1257 let mut state = mock_state();
1258 let mut rstate = test_cache();
1259 render_frame(&state, &mut rstate, 80, 24);
1260 assert_eq!(rstate.theme.name, "Dark");
1261 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1262 render_frame(&state, &mut rstate, 80, 24);
1263 assert_eq!(rstate.theme.name, "Light");
1264 state.ui.no_color = true;
1266 render_frame(&state, &mut rstate, 80, 24);
1267 assert_eq!(rstate.theme.name, "Plain");
1268 }
1269
1270 #[test]
1271 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1272 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1273
1274 let mut state = mock_state();
1275 let call_id = ToolCallId(7);
1276 state.turn = TurnState::ExecutingTools {
1277 id: TurnId(1),
1278 started: std::time::SystemTime::now(),
1279 calls: vec![PendingToolCall {
1280 call_id,
1281 source: mermaid_model::models::tool_call::ToolCall {
1282 id: None,
1283 function: mermaid_model::models::tool_call::FunctionCall {
1284 name: "agent".to_string(),
1285 arguments: serde_json::json!({"description": "explore crates"}),
1286 },
1287 },
1288 }],
1289 outcomes: vec![None],
1290 };
1291 state.ui.live_tool_status.insert(
1292 call_id,
1293 LiveToolStatus {
1294 activity: "read_file…".to_string(),
1295 tokens: 12_300,
1296 },
1297 );
1298
1299 let live = build_live_messages(
1302 &[],
1303 &state.turn,
1304 chrono::Local::now(),
1305 mermaid_model::safety::HostShell::Posix,
1306 );
1307 assert!(
1308 live.is_empty(),
1309 "a pending agent call must not synthesize a transcript row"
1310 );
1311 let (rows, override_text, bg_available) = agent_panel_data(&state);
1312 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1313 assert!(bg_available, "agents are detachable via ctrl+b");
1314 assert_eq!(rows.len(), 1);
1315 assert_eq!(rows[0].description, "explore crates");
1316 assert_eq!(rows[0].activity, "read_file…");
1317 assert_eq!(rows[0].tokens, 12_300);
1318 assert!(!rows[0].backgrounded);
1319 }
1320
1321 #[test]
1322 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1323 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1324
1325 let mut state = mock_state();
1326 let exec_id = ToolCallId(8);
1327 let agent_id = ToolCallId(9);
1328 let call = |id, name: &str, args| PendingToolCall {
1329 call_id: id,
1330 source: mermaid_model::models::tool_call::ToolCall {
1331 id: None,
1332 function: mermaid_model::models::tool_call::FunctionCall {
1333 name: name.to_string(),
1334 arguments: args,
1335 },
1336 },
1337 };
1338 state.turn = TurnState::ExecutingTools {
1339 id: TurnId(1),
1340 started: std::time::SystemTime::now(),
1341 calls: vec![
1342 call(
1343 exec_id,
1344 "execute_command",
1345 serde_json::json!({"command": "cargo test"}),
1346 ),
1347 call(
1348 agent_id,
1349 "agent",
1350 serde_json::json!({"description": "audit docs"}),
1351 ),
1352 ],
1353 outcomes: vec![None, None],
1354 };
1355 state.ui.live_tool_status.insert(
1356 exec_id,
1357 LiveToolStatus {
1358 activity: String::new(),
1359 tokens: 0,
1360 },
1361 );
1362
1363 let live = build_live_messages(
1366 &[],
1367 &state.turn,
1368 chrono::Local::now(),
1369 mermaid_model::safety::HostShell::Posix,
1370 );
1371 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1372 let actions = &live[0].actions;
1373 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1374 assert_eq!(actions[0].action_type, "Bash");
1375 assert_eq!(actions[0].target, "cargo test");
1376 assert!(matches!(
1377 actions[0].result,
1378 mermaid_domain::ActionResult::Running
1379 ));
1380 let (rows, override_text, _) = agent_panel_data(&state);
1381 assert_eq!(override_text, None);
1382 assert_eq!(rows.len(), 1);
1383 }
1384
1385 #[test]
1386 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1387 use mermaid_domain::{GenPhase, TurnId};
1388 use mermaid_model::models::ChatMessage;
1389 use std::borrow::Cow;
1390 use std::time::SystemTime;
1391
1392 let committed = vec![ChatMessage::user("hi")];
1393 let now = chrono::Local::now();
1394
1395 let idle = build_live_messages(
1397 &committed,
1398 &TurnState::Idle,
1399 now,
1400 mermaid_model::safety::HostShell::Posix,
1401 );
1402 assert!(matches!(idle, Cow::Borrowed(_)));
1403 assert_eq!(idle.len(), 1);
1404
1405 let turn = TurnState::Generating {
1408 id: TurnId(1),
1409 started: SystemTime::now(),
1410 partial_text: "draft".to_string(),
1411 partial_reasoning: String::new(),
1412 tokens: 0,
1413 phase: GenPhase::Sending,
1414 provider_continuation: None,
1415 pending_tool_calls: Vec::new(),
1416 continuation: false,
1417 };
1418 let live = build_live_messages(
1419 &committed,
1420 &turn,
1421 now,
1422 mermaid_model::safety::HostShell::Posix,
1423 );
1424 assert!(matches!(live, Cow::Owned(_)));
1425 assert_eq!(live.len(), 2);
1426 assert_eq!(live[1].timestamp, now);
1427 }
1428
1429 fn kinded(
1430 mut msg: mermaid_model::models::ChatMessage,
1431 kind: mermaid_model::models::ChatMessageKind,
1432 ) -> mermaid_model::models::ChatMessage {
1433 msg.kind = kind;
1434 msg
1435 }
1436
1437 #[test]
1438 fn stitch_committed_merges_chain_and_hides_nudges() {
1439 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1440 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1441 part1.thinking = Some("first trace".to_string());
1442 let mut part2 = kinded(
1444 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1445 ChatMessageKind::Continuation,
1446 );
1447 part2.thinking = Some("second trace".to_string());
1448 let committed = vec![
1449 ChatMessage::user("audit the widget"),
1450 part1,
1451 kinded(
1452 ChatMessage::system("resume nudge"),
1453 ChatMessageKind::RecoveryNudge,
1454 ),
1455 part2,
1456 ];
1457
1458 assert!(needs_stitch(&committed, &TurnState::Idle));
1459 let stitched = stitch_committed(&committed);
1460 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1461 assert_eq!(
1462 stitched[1].content,
1463 "The audit found three issues in the resolver, and here is the fix.",
1464 "contents merge with the resume echo trimmed"
1465 );
1466 assert_eq!(
1467 stitched[1].thinking.as_deref(),
1468 Some("first trace\n\nsecond trace"),
1469 "both reasoning segments survive in order"
1470 );
1471 assert!(
1472 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1473 "nudges never render"
1474 );
1475 }
1476
1477 #[test]
1480 fn context_markers_are_hidden_from_the_transcript() {
1481 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1482 let committed = vec![
1483 ChatMessage::user("plan this"),
1484 kinded(
1485 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1486 ChatMessageKind::ContextMarker,
1487 ),
1488 ChatMessage::assistant("Grounding first."),
1489 ];
1490 assert!(
1495 !needs_stitch(&committed, &TurnState::Idle),
1496 "a marker alone must not defeat the zero-copy path",
1497 );
1498 let stitched = stitch_committed(&committed);
1500 assert_eq!(stitched.len(), 2, "user + assistant only");
1501 assert!(
1502 !stitched
1503 .iter()
1504 .any(|m| m.content.contains("Plan mode is now ON")),
1505 "markers never render"
1506 );
1507 }
1508
1509 #[test]
1510 fn stitch_refuses_non_bubble_predecessor() {
1511 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1512 let committed = vec![
1516 kinded(
1517 ChatMessage::assistant("checkpoint summary"),
1518 ChatMessageKind::ContextCheckpoint,
1519 ),
1520 kinded(
1521 ChatMessage::assistant("orphaned continuation"),
1522 ChatMessageKind::Continuation,
1523 ),
1524 ];
1525 let stitched = stitch_committed(&committed);
1526 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1527 assert_eq!(stitched[1].content, "orphaned continuation");
1528 }
1529
1530 #[test]
1531 fn needs_stitch_is_false_for_plain_sessions() {
1532 use mermaid_model::models::ChatMessage;
1533 let committed = vec![
1536 ChatMessage::user("hi"),
1537 ChatMessage::assistant("hello"),
1538 ChatMessage::system("note"),
1539 ];
1540 assert!(!needs_stitch(&committed, &TurnState::Idle));
1541 }
1542
1543 #[test]
1551 fn a_live_continuation_still_forces_the_stitch() {
1552 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1553 let committed = vec![
1554 ChatMessage::user("write it"),
1555 ChatMessage::assistant("first half"),
1556 kinded(
1557 ChatMessage::system("output limit — continuing"),
1558 ChatMessageKind::RecoveryNudge,
1559 ),
1560 ];
1561 let streaming = TurnState::Generating {
1562 id: mermaid_domain::TurnId(1),
1563 started: std::time::SystemTime::UNIX_EPOCH,
1564 partial_text: "first half and the rest".to_string(),
1565 partial_reasoning: String::new(),
1566 tokens: 0,
1567 phase: mermaid_domain::GenPhase::Streaming,
1568 provider_continuation: None,
1569 pending_tool_calls: Vec::new(),
1570 continuation: true,
1571 };
1572 assert!(
1573 needs_stitch(&committed, &streaming),
1574 "a live continuation needs the nudge stripped to find its bubble",
1575 );
1576 let stitched = stitch_committed(&committed);
1578 assert!(
1579 stitched.last().is_some_and(mergeable_into),
1580 "the stitched tail is the assistant bubble the partial merges into",
1581 );
1582 }
1583
1584 #[test]
1585 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1586 use mermaid_domain::{GenPhase, TurnId};
1587 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1588
1589 let committed = vec![ChatMessage::assistant(
1590 "the fix lands in the resolver module",
1591 )];
1592 let turn = TurnState::Generating {
1593 id: TurnId(2),
1594 started: std::time::SystemTime::now(),
1595 partial_text: "in the resolver module, specifically the clamp".to_string(),
1596 partial_reasoning: String::new(),
1597 tokens: 0,
1598 phase: GenPhase::Streaming,
1599 provider_continuation: None,
1600 pending_tool_calls: Vec::new(),
1601 continuation: true,
1602 };
1603 let live = build_live_messages(
1604 &committed,
1605 &turn,
1606 chrono::Local::now(),
1607 mermaid_model::safety::HostShell::Posix,
1608 );
1609 let streamed = live.last().expect("pseudo-message appended");
1610 assert_eq!(
1611 streamed.kind,
1612 ChatMessageKind::Continuation,
1613 "the live half is stamped so the widget draws it prefix-less"
1614 );
1615 assert_eq!(
1616 streamed.content, ", specifically the clamp",
1617 "the leading resume echo is trimmed against the committed tail"
1618 );
1619 }
1620
1621 #[test]
1622 fn auto_continued_reply_renders_as_one_bubble() {
1623 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1624 let mut s = mock_state();
1625 s.session.append(ChatMessage::user("audit"), s.now);
1626 s.session
1627 .append(ChatMessage::assistant("part one of the reply"), s.now);
1628 s.session.append(
1629 kinded(
1630 ChatMessage::system("output limit — continuing"),
1631 ChatMessageKind::RecoveryNudge,
1632 ),
1633 s.now,
1634 );
1635 s.session.append(
1636 kinded(
1637 ChatMessage::assistant("and part two lands here"),
1638 ChatMessageKind::Continuation,
1639 ),
1640 s.now,
1641 );
1642
1643 let out = render_to_string(&s);
1644 assert!(out.contains("part one of the reply"));
1645 assert!(out.contains("and part two lands here"));
1646 assert!(
1647 !out.contains("continuing"),
1648 "the recovery nudge never renders"
1649 );
1650 assert_eq!(
1651 out.matches('●').count(),
1652 1,
1653 "both halves share one assistant bullet:\n{out}"
1654 );
1655 }
1656
1657 #[test]
1658 fn streaming_continuation_renders_without_fresh_bullet() {
1659 use mermaid_domain::{GenPhase, TurnId};
1660 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1661 let mut s = mock_state();
1662 s.session.append(ChatMessage::user("audit"), s.now);
1663 s.session
1664 .append(ChatMessage::assistant("part one of the reply"), s.now);
1665 s.session.append(
1666 kinded(
1667 ChatMessage::system("output limit — continuing"),
1668 ChatMessageKind::RecoveryNudge,
1669 ),
1670 s.now,
1671 );
1672 s.turn = TurnState::Generating {
1673 id: TurnId(3),
1674 started: std::time::SystemTime::now(),
1675 partial_text: "and part two streams in".to_string(),
1676 partial_reasoning: String::new(),
1677 tokens: 0,
1678 phase: GenPhase::Streaming,
1679 provider_continuation: None,
1680 pending_tool_calls: Vec::new(),
1681 continuation: true,
1682 };
1683
1684 let out = render_to_string(&s);
1685 assert!(out.contains("part one of the reply"));
1686 assert!(out.contains("and part two streams in"));
1687 assert!(!out.contains("continuing"), "live nudge hidden too");
1688 assert_eq!(
1689 out.matches('●').count(),
1690 1,
1691 "the streaming half joins the committed bubble:\n{out}"
1692 );
1693 }
1694
1695 #[test]
1696 fn user_prompt_renders_with_highlight_band() {
1697 let mut s = mock_state();
1698 s.session.append(
1699 mermaid_model::models::ChatMessage::user("hello there"),
1700 s.now,
1701 );
1702 let buf = render_to_buffer(&s);
1703 let band_bg = crate::render::theme::Theme::dark()
1704 .colors
1705 .user_message_background
1706 .to_color();
1707 let y = (0..buf.area.height)
1709 .find(|&y| {
1710 (0..buf.area.width)
1711 .map(|x| buf[(x, y)].symbol())
1712 .collect::<String>()
1713 .contains("hello there")
1714 })
1715 .expect("user prompt should render");
1716 let banded = (0..buf.area.width)
1719 .filter(|&x| buf[(x, y)].bg == band_bg)
1720 .count();
1721 assert!(
1722 banded >= (buf.area.width as usize) * 3 / 4,
1723 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1724 buf.area.width
1725 );
1726 }
1727
1728 #[test]
1729 fn idle_state_renders_cwd_and_model_footer() {
1730 let s = mock_state();
1731 let frame = render_to_string(&s);
1732 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1734 assert!(frame.contains("ollama/test"));
1735 }
1736
1737 #[test]
1738 fn status_line_appears_during_generating() {
1739 let mut s = mock_state();
1740 s.turn = mermaid_domain::transition::start_generating(
1741 mermaid_domain::TurnId(1),
1742 std::time::SystemTime::now(),
1743 );
1744 let frame = render_to_string(&s);
1745 assert!(
1746 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1747 "expected generation status in frame"
1748 );
1749 }
1750
1751 #[test]
1752 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1753 use mermaid_domain::PendingToolCall;
1754 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1755 let mut s = mock_state();
1756 let call = PendingToolCall {
1757 call_id: mermaid_domain::ToolCallId(1),
1758 source: ModelToolCall {
1759 id: Some("c1".to_string()),
1760 function: FunctionCall {
1761 name: "execute_command".to_string(),
1762 arguments: serde_json::json!({"command": "npm run dev"}),
1763 },
1764 },
1765 };
1766 s.turn = TurnState::ExecutingTools {
1767 id: mermaid_domain::TurnId(1),
1768 started: std::time::SystemTime::now(),
1769 calls: vec![call],
1770 outcomes: vec![None],
1771 };
1772 let frame = render_to_string(&s);
1773 assert!(frame.contains("Running tools..."), "got: {frame}");
1776 assert!(
1777 !frame.contains("Running tools:"),
1778 "status line must not carry tool detail; got: {frame}"
1779 );
1780 assert!(
1782 frame.contains("npm run dev"),
1783 "transcript must show the in-flight call's action row; got: {frame}"
1784 );
1785 }
1786
1787 #[test]
1788 fn pending_question_and_agent_calls_get_no_transcript_row() {
1789 use mermaid_domain::PendingToolCall;
1790 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1791 let mut s = mock_state();
1792 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1793 call_id: mermaid_domain::ToolCallId(id),
1794 source: ModelToolCall {
1795 id: Some(format!("c{id}")),
1796 function: FunctionCall {
1797 name: name.to_string(),
1798 arguments: args,
1799 },
1800 },
1801 };
1802 s.turn = TurnState::ExecutingTools {
1803 id: mermaid_domain::TurnId(1),
1804 started: std::time::SystemTime::now(),
1805 calls: vec![
1806 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1807 mk(
1808 2,
1809 "agent",
1810 serde_json::json!({"description": "scan the repo"}),
1811 ),
1812 ],
1813 outcomes: vec![None, None],
1814 };
1815 let frame = render_to_string(&s);
1816 assert!(
1819 !frame.contains("ask_user_question"),
1820 "pending question must not surface as a transcript row or status text; got: {frame}"
1821 );
1822 }
1823
1824 #[test]
1825 fn status_line_appears_during_tool_execution_and_shows_queue() {
1826 let mut s = mock_state();
1827 s.turn = TurnState::ExecutingTools {
1828 id: mermaid_domain::TurnId(1),
1829 started: std::time::SystemTime::now(),
1830 calls: Vec::new(),
1831 outcomes: Vec::new(),
1832 };
1833 s.ui.queued_messages
1834 .push_back(mermaid_domain::QueuedMessage {
1835 text: "please steer this".to_string(),
1836 attachment_ids: Vec::new(),
1837 });
1838 let frame = render_to_string(&s);
1839 assert!(frame.contains("Running tools"), "expected tool status");
1840 assert!(
1841 frame.contains("please steer this"),
1842 "queued busy input must be visible"
1843 );
1844 }
1845
1846 #[test]
1847 fn reasoning_blocks_are_collapsed_by_default() {
1848 let mut s = mock_state();
1849 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1850 first_msg.thinking = Some("first private chain of thought".to_string());
1851 s.session.append(first_msg, s.now);
1852 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1853 second_msg.thinking = Some("second private chain of thought".to_string());
1854 s.session.append(second_msg, s.now);
1855 let frame = render_to_string(&s);
1856 assert!(!frame.contains("Reasoning hidden"));
1858 assert!(frame.contains("first visible answer"));
1859 assert!(frame.contains("second visible answer"));
1860 assert!(!frame.contains("first private chain of thought"));
1861 assert!(!frame.contains("second private chain of thought"));
1862 }
1863
1864 #[test]
1868 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1869 let mut s = mock_state();
1870 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1871 msg.thinking = Some("private chain of thought".to_string());
1872 msg.actions.push(mermaid_domain::ActionDisplay {
1873 action_type: "Bash".to_string(),
1874 target: "dir".to_string(),
1875 result: mermaid_domain::ActionResult::Success {
1876 output: "ok".to_string(),
1877 images: None,
1878 },
1879 details: mermaid_domain::ActionDetails::Simple,
1880 duration_seconds: Some(0.015),
1881 metadata: None,
1882 });
1883 s.session.append(msg, s.now);
1884 let frame = render_to_string(&s);
1885 assert!(
1886 !frame.contains("Reasoning hidden"),
1887 "no reasoning-hidden placeholder"
1888 );
1889 assert!(
1890 frame.contains("Bash"),
1891 "the action still renders even though reasoning is hidden"
1892 );
1893 }
1894
1895 #[test]
1896 fn committed_message_appears_in_chat_pane() {
1897 let mut s = mock_state();
1898 s.session.append(
1899 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1900 s.now,
1901 );
1902 let frame = render_to_string(&s);
1903 assert!(frame.contains("unique-user-token-xyz"));
1904 }
1905
1906 #[test]
1907 fn palette_renders_when_input_starts_with_slash() {
1908 let mut s = mock_state();
1909 s.ui.input_buffer = "/help".to_string();
1910 s.ui.input_cursor = 5;
1911 let frame = render_to_string(&s);
1912 assert!(frame.contains("help"));
1914 }
1915
1916 #[test]
1917 fn status_line_helper_maps_idle_to_idle() {
1918 assert_eq!(
1919 GenerationStatus::from_turn(&TurnState::Idle),
1920 GenerationStatus::Idle
1921 );
1922 }
1923
1924 #[test]
1936 fn the_caret_lands_inside_the_input_box_and_no_row_is_clipped() {
1937 use ratatui::Terminal;
1938 use ratatui::backend::TestBackend;
1939
1940 let text = "Create a language that. Your goal is up to you.";
1941 for width in [24u16, 30, 40, 55] {
1942 for n in 0..=text.len() {
1943 let Some(prefix) = text.get(..n) else {
1946 continue;
1947 };
1948 let mut state = mock_state();
1949 state.ui.input_buffer = prefix.to_string();
1950 state.ui.input_cursor = prefix.len();
1951
1952 let mut terminal = Terminal::new(TestBackend::new(width, 24)).expect("terminal");
1953 let mut rstate = test_cache();
1954 terminal
1955 .draw(|f| render(&state, &mut rstate, f))
1956 .expect("draw");
1957 let cursor = terminal.get_cursor_position().expect("cursor");
1958 let buf = terminal.backend().buffer().clone();
1959
1960 let row_at = |y: u16| {
1961 (0..buf.area.width)
1962 .map(|x| buf[(x, y)].symbol())
1963 .collect::<String>()
1964 };
1965 let caret_row = row_at(cursor.y);
1966
1967 assert!(
1970 !caret_row.contains('─'),
1971 "caret on the box border at width {width}, prefix {prefix:?}\n\
1972 row {}: |{caret_row}|",
1973 cursor.y
1974 );
1975
1976 let content_width = width.saturating_sub(2) as usize;
1978 let rows = widgets::rendered_row_count(prefix, content_width);
1979 assert!(
1980 rows <= 5,
1981 "fixture outgrew the 5-row cap at width {width}: {prefix:?}"
1982 );
1983 if let Some(last) = prefix.split_whitespace().next_back() {
1984 let frame: String = (0..buf.area.height)
1985 .map(row_at)
1986 .collect::<Vec<_>>()
1987 .join("\n");
1988 assert!(
1989 frame.contains(last),
1990 "last word {last:?} clipped at width {width}, \
1991 prefix {prefix:?}\n{frame}"
1992 );
1993 }
1994 }
1995 }
1996 }
1997}