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;
29use unicode_width::UnicodeWidthChar;
30
31use mermaid_domain::{State, TurnState};
32use mermaid_model::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
33
34use widgets::{
35 ChatState, ChatWidget, GenerationStatus, InputState, InputWidget, SlashPaletteWidget,
36 StatusWidget, build_status_lines,
37};
38
39pub struct RenderCache {
48 pub chat: ChatState,
49 pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
53 stitched: Option<StitchedMemo>,
59 pub theme: theme::Theme,
60 applied_theme: Option<(mermaid_domain::ThemeChoice, bool)>,
66 pub hostname: String,
73 pub username: String,
74 pub version: String,
78 last_mouse_scroll_accum: i32,
82 last_scroll_to_bottom_seq: u32,
85}
86
87struct StitchedMemo {
89 key: u64,
90 messages: Vec<mermaid_model::models::ChatMessage>,
91}
92
93impl RenderCache {
94 #[must_use]
101 pub fn new(hostname: String, username: String) -> Self {
102 Self {
103 chat: ChatState::new(),
104 wrapped_line_cache: FxHashMap::default(),
105 theme: theme::Theme::dark(),
106 hostname,
107 username,
108 version: env!("CARGO_PKG_VERSION").to_string(),
109 stitched: None,
110 applied_theme: None,
111 last_mouse_scroll_accum: 0,
112 last_scroll_to_bottom_seq: 0,
113 }
114 }
115}
116
117#[expect(
119 clippy::too_many_lines,
120 reason = "predates the lint; see .github/baselines/expect_budget.txt"
121)]
122pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
123 let want = (state.ui.theme, state.ui.no_color);
126 if rstate.applied_theme != Some(want) {
127 rstate.theme = if state.ui.no_color {
128 theme::Theme::plain()
129 } else {
130 match state.ui.theme {
131 mermaid_domain::ThemeChoice::Dark => theme::Theme::dark(),
132 mermaid_domain::ThemeChoice::Light => theme::Theme::light(),
133 }
134 };
135 rstate.wrapped_line_cache.clear();
138 rstate.applied_theme = Some(want);
139 }
140
141 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
146 if pending > 0 {
147 rstate.chat.scroll_up(pending as u16);
148 } else if pending < 0 {
149 rstate.chat.scroll_down((-pending) as u16);
150 }
151 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
152 if state.ui.scroll_to_bottom_seq != rstate.last_scroll_to_bottom_seq {
154 rstate.chat.resume_auto_scroll();
155 rstate.last_scroll_to_bottom_seq = state.ui.scroll_to_bottom_seq;
156 }
157
158 let approval_item = state.pending_approval.front();
161 let question_item = if approval_item.is_none() {
162 state.pending_question.front()
163 } else {
164 None
165 };
166 let question_modal_open = question_item.is_some();
171
172 let terminal_width = frame.area().width.saturating_sub(4) as usize;
174 let input_lines = if state.ui.input_buffer.is_empty() {
175 1
176 } else {
177 let mut lines = 1usize;
178 let mut col = 0usize;
179 for ch in state.ui.input_buffer.chars() {
180 let w = ch.width().unwrap_or(0);
181 if ch == '\n' || col >= terminal_width {
182 lines += 1;
183 col = if ch == '\n' { 0 } else { w };
184 } else {
185 col += w;
186 }
187 }
188 lines.min(5)
189 };
190 let input_height = if question_modal_open {
191 0
192 } else {
193 (input_lines + 2) as u16
194 };
195
196 let status_lines = if question_modal_open {
201 Vec::new()
202 } else if state.is_busy() {
203 let now_sys = std::time::SystemTime::from(state.now);
207 let elapsed_since =
208 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
209 let elapsed_secs = match &state.turn {
210 TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
213 state
214 .runtime
215 .run_started
216 .map_or_else(|| elapsed_since(*started), elapsed_since)
217 },
218 TurnState::Compacting { started, .. } => elapsed_since(*started),
219 TurnState::Cancelling { since, .. } => elapsed_since(*since),
220 TurnState::Idle => 0,
221 };
222 let (agent_rows, status_override, bg_available) = agent_panel_data(state);
223 let task_headline = state
227 .session
228 .conversation
229 .tasks
230 .active()
231 .map(|t| t.active_form.clone());
232 let committed = state.runtime.run_tokens;
241 let live_child_tokens: usize = state.ui.live_tool_status.values().map(|l| l.tokens).sum();
242 let (tokens_display, tokens_estimated) = match &state.turn {
243 TurnState::Generating { tokens, .. } => (committed.output_tokens + *tokens, true),
244 TurnState::ExecutingTools { .. } => (
245 committed.output_tokens + live_child_tokens,
246 committed.contains_estimate || live_child_tokens > 0,
247 ),
248 _ => (0, false),
249 };
250 build_status_lines(
251 GenerationStatus::from_turn(&state.turn),
252 elapsed_secs,
253 tokens_display,
254 tokens_estimated,
255 status_override.as_deref(),
256 &agent_rows,
257 bg_available,
258 task_headline.as_deref(),
259 &state.ui.queued_messages,
260 exit_armed(state),
261 &rstate.theme,
262 frame.area().width.saturating_sub(2),
264 )
265 } else if !state.runtime.background_agents.is_empty() {
266 let (agent_rows, _, _) = agent_panel_data(state);
269 build_status_lines(
270 GenerationStatus::Idle,
271 0,
272 0,
273 false,
274 None,
275 &agent_rows,
276 false,
277 None,
278 &state.ui.queued_messages,
279 exit_armed(state),
280 &rstate.theme,
281 frame.area().width.saturating_sub(2),
282 )
283 } else {
284 Vec::new()
285 };
286
287 let status_reserve = 10 + input_height + 2;
292 let status_line_height = (status_lines.len() as u16)
293 .min(14)
294 .min(frame.area().height.saturating_sub(status_reserve));
295
296 let tasks_store = &state.session.conversation.tasks;
301 let tasks_attached = status_line_height > 0;
302 let tasks_zone_height = if question_modal_open {
303 0
304 } else if widgets::tasks_visible(
305 tasks_store,
306 &state.turn,
307 state.ui.tasks_collapsed,
308 tasks_attached,
309 ) {
310 widgets::tasks_height(tasks_store, state.ui.tasks_collapsed).min(
311 frame
312 .area()
313 .height
314 .saturating_sub(status_reserve + status_line_height),
315 )
316 } else {
317 0
318 };
319
320 let confirm_open =
330 approval_item.is_none() && question_item.is_none() && state.confirm.is_some();
331 let conv_list_open = approval_item.is_none()
332 && question_item.is_none()
333 && !confirm_open
334 && matches!(
335 state.ui.mode,
336 mermaid_domain::UiMode::ConversationList { .. }
337 );
338 let rewind_open = approval_item.is_none()
339 && question_item.is_none()
340 && !confirm_open
341 && matches!(state.ui.mode, mermaid_domain::UiMode::RewindPicker { .. });
342 let plan_config_open = approval_item.is_none()
343 && question_item.is_none()
344 && !confirm_open
345 && matches!(state.ui.mode, mermaid_domain::UiMode::PlanConfig { .. });
346 let model_picker_open = approval_item.is_none()
347 && question_item.is_none()
348 && !confirm_open
349 && matches!(state.ui.mode, mermaid_domain::UiMode::ModelPicker { .. });
350 let file_picker_open = approval_item.is_none()
351 && question_item.is_none()
352 && !confirm_open
353 && !conv_list_open
354 && !rewind_open
355 && !plan_config_open
356 && state.ui.file_picker_open();
357 let palette_open = approval_item.is_none()
358 && question_item.is_none()
359 && !confirm_open
360 && !conv_list_open
361 && !file_picker_open
362 && state.ui.input_buffer.starts_with('/');
363 let bottom_height = if let Some(item) = approval_item {
364 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
366 2 + body_lines + 1 + 3
367 } else if let Some(qset) = question_item {
368 widgets::question_modal_height(qset, &rstate.theme, frame.area().width)
371 } else if confirm_open {
372 6
373 } else if conv_list_open || rewind_open {
374 12
375 } else if plan_config_open {
376 widgets::PLAN_CONFIG_HEIGHT
377 } else if model_picker_open {
378 widgets::MODEL_PICKER_HEIGHT
379 } else if file_picker_open {
380 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
381 (rows as u16) + 2
382 } else if palette_open {
383 let typed = state
384 .ui
385 .input_buffer
386 .trim_start_matches('/')
387 .split_whitespace()
388 .next()
389 .unwrap_or("");
390 let row_count =
391 mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands)
392 .len()
393 .clamp(1, 8);
394 (row_count as u16) + 2
395 } else {
396 2
397 };
398
399 use ratatui::layout::{Constraint, Direction, Layout};
403 let chunks = Layout::default()
404 .direction(Direction::Vertical)
405 .constraints([
406 Constraint::Min(10),
407 Constraint::Length(status_line_height),
408 Constraint::Length(tasks_zone_height),
409 Constraint::Length(input_height),
410 Constraint::Length(bottom_height),
411 ])
412 .split(frame.area());
413
414 let chat_area = chunks[0].inner(Margin {
416 horizontal: 1,
417 vertical: 0,
418 });
419 let toast = active_toast(state);
424 let (chat_area, toast_area) = match toast {
425 Some(_) if chat_area.height > 1 => (
426 Rect {
427 height: chat_area.height - 1,
428 ..chat_area
429 },
430 Some(Rect {
431 y: chat_area.y + chat_area.height - 1,
432 height: 1,
433 ..chat_area
434 }),
435 ),
436 _ => (chat_area, None),
437 };
438 let committed = state.session.messages();
443 let base: &[mermaid_model::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
444 let key = stitch_fingerprint(committed);
445 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
446 rstate.stitched = Some(StitchedMemo {
447 key,
448 messages: stitch_committed(committed),
449 });
450 }
451 &rstate
452 .stitched
453 .as_ref()
454 .expect("stitched memo populated above")
455 .messages
456 } else {
457 committed
458 };
459 let live_messages = build_live_messages(base, &state.turn, state.now);
460 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
463 let chat_widget = ChatWidget {
464 messages: live_messages.as_ref(),
465 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
466 theme: &rstate.theme,
467 wrapped_line_cache: &mut rstate.wrapped_line_cache,
468 show_reasoning: state.ui.show_reasoning,
469 blink_on,
470 today: state.now.date_naive(),
471 };
472 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
473
474 if let (Some(text), Some(area)) = (toast, toast_area) {
477 frame.render_widget(
478 ratatui::widgets::Paragraph::new(Line::from(Span::styled(
479 text,
480 Style::new().fg(rstate.theme.colors.info.to_color()),
481 )))
482 .alignment(ratatui::layout::Alignment::Right),
483 area,
484 );
485 }
486
487 if !status_lines.is_empty() {
490 let status_area = chunks[1].inner(Margin {
491 horizontal: 1,
492 vertical: 0,
493 });
494 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
495 }
496
497 if tasks_zone_height > 0 {
499 let tasks_area = chunks[2].inner(Margin {
500 horizontal: 1,
501 vertical: 0,
502 });
503 let lines = widgets::build_task_lines(
504 tasks_store,
505 state.ui.tasks_collapsed,
506 tasks_attached,
507 tasks_area.width,
508 &rstate.theme,
509 );
510 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
511 }
512
513 if !question_modal_open {
517 let input_widget = InputWidget {
518 input: state.ui.input_buffer.as_str(),
519 showing_command_hints: state.ui.input_buffer.starts_with('/'),
520 theme: &rstate.theme,
521 reasoning_active: state.session.reasoning != ReasoningLevel::None,
522 exit_armed: exit_armed(state),
523 rewind_armed: rewind_armed(state),
524 };
525 let mut input_widget_state = InputState {
526 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
527 };
528 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
529
530 let input_area = chunks[3];
532 let content_width = input_area.width.saturating_sub(2) as usize;
533 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
534 &state.ui.input_buffer,
535 state.ui.input_cursor.min(state.ui.input_buffer.len()),
536 content_width,
537 );
538 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
539 }
540
541 let requested = state.session.reasoning;
545 let effective = match supported_reasoning_for(state) {
546 Some(ReasoningCapability::Levels(supp)) => {
547 nearest_effort(requested, &supp).unwrap_or(requested)
548 },
549 _ => requested,
550 };
551 let requested_level = if effective == requested {
552 None
553 } else {
554 Some(requested)
555 };
556
557 if let Some(item) = state.pending_approval.front() {
560 use widgets::ApprovalModalWidget;
561 let options = if item.allowlist_scope.is_empty() {
565 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
566 } else {
567 vec![
568 "1. Yes".to_string(),
569 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
570 "3. No (Esc)".to_string(),
571 ]
572 };
573 let widget = ApprovalModalWidget {
574 theme: &rstate.theme,
575 title: format!("Approval required — {} [{}]", item.tool, item.risk),
576 body: item.prompt.as_str(),
577 options,
578 selected_index: Some(item.selected_option),
579 accent: rstate.theme.colors.warning.to_color(),
580 };
581 frame.render_widget(widget, chunks[4]);
582 } else if let Some(qset) = state.pending_question.front() {
583 use widgets::QuestionModalWidget;
584 let widget = QuestionModalWidget {
585 theme: &rstate.theme,
586 set: qset,
587 width: chunks[4].width,
588 };
589 frame.render_widget(widget, chunks[4]);
590 } else if let Some(confirm) = &state.confirm {
591 use widgets::ApprovalModalWidget;
592 let widget = ApprovalModalWidget {
593 theme: &rstate.theme,
594 title: "Confirm".to_string(),
595 body: confirm.prompt.as_str(),
596 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
597 selected_index: None,
598 accent: rstate.theme.colors.warning.to_color(),
599 };
600 frame.render_widget(widget, chunks[4]);
601 } else if let mermaid_domain::UiMode::ModelPicker {
602 candidates,
603 query,
604 cursor,
605 loading,
606 } = &state.ui.mode
607 {
608 use widgets::ModelPickerWidget;
609 let matches = mermaid_domain::reducer::filter_model_choices(candidates, query);
610 let widget = ModelPickerWidget {
611 theme: &rstate.theme,
612 matches: &matches,
613 query,
614 cursor: *cursor,
615 loading: *loading,
616 current: &state.session.model_id,
617 };
618 frame.render_widget(widget, chunks[4]);
619 } else if let mermaid_domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
620 use widgets::ConversationListWidget;
621 let widget = ConversationListWidget {
622 theme: &rstate.theme,
623 candidates,
624 cursor: *cursor,
625 };
626 frame.render_widget(widget, chunks[4]);
627 } else if let mermaid_domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
628 use widgets::RewindPickerWidget;
629 let widget = RewindPickerWidget {
630 theme: &rstate.theme,
631 candidates,
632 cursor: *cursor,
633 };
634 frame.render_widget(widget, chunks[4]);
635 } else if let mermaid_domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
636 use widgets::PlanConfigWidget;
637 let widget = PlanConfigWidget {
638 theme: &rstate.theme,
639 plan: &state.settings.plan,
640 session_model: &state.session.model_id,
641 cursor: *cursor,
642 };
643 frame.render_widget(widget, chunks[4]);
644 } else if file_picker_open {
645 use widgets::FilePickerWidget;
646 let widget = FilePickerWidget {
647 theme: &rstate.theme,
648 matches: &state.ui.file_picker_matches,
649 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
650 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
651 };
652 frame.render_widget(widget, chunks[4]);
653 } else if palette_open {
654 let typed = state
655 .ui
656 .input_buffer
657 .trim_start_matches('/')
658 .split_whitespace()
659 .next()
660 .unwrap_or("");
661 let entries = mermaid_domain::slash_commands::filter_entries(typed, &state.plugin_commands);
662 let palette_widget = SlashPaletteWidget {
663 theme: &rstate.theme,
664 entries,
665 selected_index: state.ui.palette_cursor.unwrap_or(0),
666 };
667 frame.render_widget(palette_widget, chunks[4]);
668 } else {
669 let cwd = state.cwd.display().to_string();
670 let status_widget = StatusWidget {
671 theme: &rstate.theme,
672 working_dir: &cwd,
673 hostname: &rstate.hostname,
674 username: &rstate.username,
675 version: &rstate.version,
676 context_usage: state.session.context_usage.as_ref(),
677 model_name: &state.session.model_id,
678 reasoning_level: effective,
679 requested_level,
680 safety_mode: state.session.safety_mode,
683 };
684 frame.render_widget(status_widget, chunks[4]);
685 }
686}
687
688pub(crate) fn mergeable_into(prev: &mermaid_model::models::ChatMessage) -> bool {
695 prev.role == mermaid_model::models::MessageRole::Assistant
696 && matches!(
697 prev.kind,
698 mermaid_model::models::ChatMessageKind::Normal
699 | mermaid_model::models::ChatMessageKind::Continuation
700 )
701 && prev.tool_calls.is_none()
702}
703
704fn chat_content_key(
724 state: &State,
725 base: &[mermaid_model::models::ChatMessage],
726 live: &[mermaid_model::models::ChatMessage],
727 blink_on: bool,
728) -> u64 {
729 use std::hash::{Hash, Hasher};
730 let mut h = rustc_hash::FxHasher::default();
731 state.session.conversation.revision().hash(&mut h);
732 base.len().hash(&mut h);
735 for msg in live.iter().skip(base.len()) {
736 msg.content.hash(&mut h);
737 msg.thinking.hash(&mut h);
738 std::mem::discriminant(&msg.kind).hash(&mut h);
739 msg.actions.len().hash(&mut h);
740 for action in &msg.actions {
741 action.action_type.hash(&mut h);
742 action.target.hash(&mut h);
743 std::mem::discriminant(&action.result).hash(&mut h);
744 }
745 }
746 if !matches!(state.turn, TurnState::Idle) {
747 blink_on.hash(&mut h);
748 }
749 h.finish()
750}
751
752fn needs_stitch(committed: &[mermaid_model::models::ChatMessage], turn: &TurnState) -> bool {
773 let live_continuation = matches!(
774 turn,
775 TurnState::Generating { continuation, .. } if *continuation
776 );
777 live_continuation
778 || committed
779 .iter()
780 .any(|m| m.kind == mermaid_model::models::ChatMessageKind::Continuation)
781}
782
783fn stitch_fingerprint(committed: &[mermaid_model::models::ChatMessage]) -> u64 {
789 use std::hash::{Hash, Hasher};
790 use std::mem::discriminant;
791
792 let mut h = rustc_hash::FxHasher::default();
793 committed.len().hash(&mut h);
794 for msg in committed {
795 msg.content.hash(&mut h);
796 msg.thinking.hash(&mut h);
797 msg.timestamp.timestamp().hash(&mut h);
798 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
799 msg.image_numbers
800 .as_ref()
801 .map_or(0, |v| v.len())
802 .hash(&mut h);
803 discriminant(&msg.role).hash(&mut h);
804 discriminant(&msg.kind).hash(&mut h);
805 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
806 msg.actions.len().hash(&mut h);
814 for action in &msg.actions {
815 action.action_type.hash(&mut h);
816 action.target.hash(&mut h);
817 discriminant(&action.result).hash(&mut h);
818 discriminant(&action.details).hash(&mut h);
819 action.duration_seconds.map(f64::to_bits).hash(&mut h);
820 if let Some(meta) = &action.metadata {
821 meta.lines_added.hash(&mut h);
822 meta.lines_removed.hash(&mut h);
823 meta.diff_truncated.hash(&mut h);
824 meta.display_diff.as_ref().map(String::len).hash(&mut h);
825 }
826 }
827 }
828 h.finish()
829}
830
831fn stitch_committed(
843 committed: &[mermaid_model::models::ChatMessage],
844) -> Vec<mermaid_model::models::ChatMessage> {
845 let mut out: Vec<mermaid_model::models::ChatMessage> = Vec::with_capacity(committed.len());
846 for msg in committed {
847 if matches!(
848 msg.kind,
849 mermaid_model::models::ChatMessageKind::RecoveryNudge
850 | mermaid_model::models::ChatMessageKind::ContextMarker
851 ) {
852 continue;
853 }
854 if msg.kind == mermaid_model::models::ChatMessageKind::Continuation
855 && let Some(prev) = out.last_mut()
856 && mergeable_into(prev)
857 {
858 merge_continuation(prev, msg);
859 continue;
860 }
861 out.push(msg.clone());
862 }
863 out
864}
865
866fn merge_continuation(
870 prev: &mut mermaid_model::models::ChatMessage,
871 cont: &mermaid_model::models::ChatMessage,
872) {
873 let skip = mermaid_model::utils::continuation_overlap(&prev.content, &cont.content);
874 prev.content.push_str(&cont.content[skip..]);
875 if let Some(cont_thinking) = &cont.thinking {
876 match &mut prev.thinking {
877 Some(t) => {
878 t.push_str("\n\n");
879 t.push_str(cont_thinking);
880 },
881 None => prev.thinking = Some(cont_thinking.clone()),
882 }
883 }
884 prev.actions.extend(cont.actions.iter().cloned());
885 if let Some(imgs) = &cont.images {
886 prev.images
887 .get_or_insert_with(Vec::new)
888 .extend(imgs.iter().cloned());
889 }
890 if let Some(nums) = &cont.image_numbers {
891 prev.image_numbers
892 .get_or_insert_with(Vec::new)
893 .extend(nums.iter().copied());
894 }
895 if cont.tool_calls.is_some() {
898 prev.tool_calls = cont.tool_calls.clone();
899 }
900}
901
902fn build_live_messages<'a>(
922 committed: &'a [mermaid_model::models::ChatMessage],
923 turn: &TurnState,
924 now: chrono::DateTime<chrono::Local>,
925) -> std::borrow::Cow<'a, [mermaid_model::models::ChatMessage]> {
926 if let TurnState::ExecutingTools {
927 calls, outcomes, ..
928 } = turn
929 {
930 let actions: Vec<mermaid_domain::ActionDisplay> = calls
931 .iter()
932 .zip(outcomes)
933 .filter_map(|(call, outcome)| match outcome {
934 Some(outcome) => Some(mermaid_domain::transition::action_display_for(
935 call, outcome,
936 )),
937 None => {
938 let name = call.source.function.name.as_str();
939 if name == "agent" || name == "ask_user_question" {
940 return None;
941 }
942 let (action_type, target) = mermaid_domain::display_info_for(call);
943 Some(mermaid_domain::ActionDisplay {
944 action_type,
945 target,
946 result: mermaid_domain::ActionResult::Running,
947 details: mermaid_domain::ActionDetails::Simple,
948 duration_seconds: None,
949 metadata: None,
950 })
951 },
952 })
953 .collect();
954 if actions.is_empty() {
955 return std::borrow::Cow::Borrowed(committed);
956 }
957 let mut msg = mermaid_model::models::ChatMessage::assistant("");
958 msg.timestamp = now;
959 msg.actions = actions;
960 let mut out = committed.to_vec();
961 out.push(msg);
962 return std::borrow::Cow::Owned(out);
963 }
964 if let TurnState::Generating {
968 partial_text,
969 partial_reasoning,
970 continuation,
971 ..
972 } = turn
973 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
974 {
975 let thinking = if partial_reasoning.is_empty() {
976 None
977 } else {
978 Some(partial_reasoning.clone())
979 };
980 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
981 let content = if stitching {
982 let prev = &committed[committed.len() - 1].content;
983 let skip = mermaid_model::utils::continuation_overlap(prev, partial_text);
984 partial_text[skip..].to_string()
985 } else {
986 partial_text.clone()
987 };
988 let msg = mermaid_model::models::ChatMessage {
989 role: mermaid_model::models::MessageRole::Assistant,
990 content,
991 timestamp: now,
994 kind: if stitching {
995 mermaid_model::models::ChatMessageKind::Continuation
996 } else {
997 mermaid_model::models::ChatMessageKind::Normal
998 },
999 metadata: None,
1000 actions: Vec::new(),
1001 thinking,
1002 images: None,
1003 image_numbers: None,
1004 tool_calls: None,
1005 tool_call_id: None,
1006 tool_name: None,
1007 provider_continuation: None,
1008 };
1009 let mut out = committed.to_vec();
1010 out.push(msg);
1011 std::borrow::Cow::Owned(out)
1012 } else {
1013 std::borrow::Cow::Borrowed(committed)
1014 }
1015}
1016
1017fn exit_armed(state: &State) -> bool {
1021 state
1022 .ui
1023 .exit_armed_until
1024 .is_some_and(|deadline| state.now <= deadline)
1025}
1026
1027fn active_toast(state: &State) -> Option<String> {
1031 state
1032 .ui
1033 .toast
1034 .as_ref()
1035 .filter(|(_, until)| state.now <= *until)
1036 .map(|(text, _)| text.clone())
1037}
1038
1039fn rewind_armed(state: &State) -> bool {
1042 state
1043 .ui
1044 .esc_armed_at
1045 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
1046}
1047
1048fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
1053 let now_sys = std::time::SystemTime::from(state.now);
1054 let elapsed_since =
1055 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
1056
1057 let mut rows = Vec::new();
1058 let mut running_agents = 0usize;
1059 let mut pending_total = 0usize;
1060 let mut bg_available = false;
1061 if let TurnState::ExecutingTools {
1062 calls,
1063 outcomes,
1064 started,
1065 ..
1066 } = &state.turn
1067 {
1068 let elapsed = elapsed_since(*started);
1069 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
1070 pending_total += 1;
1071 let name = call.source.function.name.as_str();
1072 if name == "execute_command" || name == "agent" {
1073 bg_available = true;
1074 }
1075 if name != "agent" {
1076 continue;
1077 }
1078 running_agents += 1;
1079 let (_, description) = mermaid_domain::display_info_for(call);
1080 let live = state.ui.live_tool_status.get(&call.call_id);
1081 rows.push(widgets::AgentPanelRow {
1082 description,
1083 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1084 tokens: live.map_or(0, |l| l.tokens),
1085 elapsed_secs: elapsed,
1086 backgrounded: false,
1087 });
1088 }
1089 }
1090 for agent in &state.runtime.background_agents {
1091 rows.push(widgets::AgentPanelRow {
1092 description: agent.description.clone(),
1093 activity: agent.activity.clone(),
1094 tokens: agent.tokens,
1095 elapsed_secs: elapsed_since(agent.started),
1096 backgrounded: true,
1097 });
1098 }
1099 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1100 if running_agents == 1 {
1101 "Running 1 agent".to_string()
1102 } else {
1103 format!("Running {running_agents} agents")
1104 }
1105 });
1106 (rows, status_override, bg_available)
1107}
1108
1109fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1114 None
1115}
1116
1117#[cfg(test)]
1122pub(crate) fn render_frame(
1123 state: &State,
1124 rstate: &mut RenderCache,
1125 width: u16,
1126 height: u16,
1127) -> String {
1128 use ratatui::Terminal;
1129 use ratatui::backend::TestBackend;
1130 let backend = TestBackend::new(width, height);
1131 let mut terminal = Terminal::new(backend).expect("terminal");
1132 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1133 let buf = terminal.backend().buffer();
1134 let mut out = String::new();
1135 for y in 0..buf.area.height {
1136 for x in 0..buf.area.width {
1137 out.push_str(buf[(x, y)].symbol());
1138 }
1139 out.push('\n');
1140 }
1141 out
1142}
1143
1144#[cfg(test)]
1148mod snapshots;
1149
1150#[cfg(test)]
1152mod bench;
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157 use mermaid_domain::Config;
1158 use mermaid_domain::{State, TurnState};
1159 use ratatui::Terminal;
1160 use ratatui::backend::TestBackend;
1161 use std::path::PathBuf;
1162
1163 fn mock_state() -> State {
1164 State::new(
1165 Config::default(),
1166 PathBuf::from("/tmp/p"),
1167 "ollama/test".to_string(),
1168 chrono::Local::now(),
1169 PathBuf::from("/tmp"),
1170 )
1171 }
1172
1173 fn test_cache() -> RenderCache {
1176 RenderCache::new("testhost".to_string(), "testuser".to_string())
1177 }
1178
1179 fn render_to_string(state: &State) -> String {
1180 render_frame(state, &mut test_cache(), 80, 24)
1181 }
1182
1183 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1184 let backend = TestBackend::new(80, 24);
1185 let mut terminal = Terminal::new(backend).expect("terminal");
1186 let mut rstate = test_cache();
1187 terminal
1188 .draw(|f| render(state, &mut rstate, f))
1189 .expect("draw");
1190 terminal.backend().buffer().clone()
1191 }
1192
1193 #[test]
1194 fn theme_choice_changes_colors_never_glyphs() {
1195 let mut state = mock_state();
1199 state
1200 .session
1201 .append(mermaid_model::models::ChatMessage::user("hello"), state.now);
1202 let dark = render_to_string(&state);
1203 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1204 let light = render_to_string(&state);
1205 assert_eq!(dark, light, "light theme changed glyphs");
1206 state.ui.no_color = true;
1207 let plain = render_to_string(&state);
1208 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1209 }
1210
1211 #[test]
1212 fn theme_memo_swaps_palette_on_state_change() {
1213 let mut state = mock_state();
1214 let mut rstate = test_cache();
1215 render_frame(&state, &mut rstate, 80, 24);
1216 assert_eq!(rstate.theme.name, "Dark");
1217 state.ui.theme = mermaid_domain::ThemeChoice::Light;
1218 render_frame(&state, &mut rstate, 80, 24);
1219 assert_eq!(rstate.theme.name, "Light");
1220 state.ui.no_color = true;
1222 render_frame(&state, &mut rstate, 80, 24);
1223 assert_eq!(rstate.theme.name, "Plain");
1224 }
1225
1226 #[test]
1227 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1228 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1229
1230 let mut state = mock_state();
1231 let call_id = ToolCallId(7);
1232 state.turn = TurnState::ExecutingTools {
1233 id: TurnId(1),
1234 started: std::time::SystemTime::now(),
1235 calls: vec![PendingToolCall {
1236 call_id,
1237 source: mermaid_model::models::tool_call::ToolCall {
1238 id: None,
1239 function: mermaid_model::models::tool_call::FunctionCall {
1240 name: "agent".to_string(),
1241 arguments: serde_json::json!({"description": "explore crates"}),
1242 },
1243 },
1244 }],
1245 outcomes: vec![None],
1246 };
1247 state.ui.live_tool_status.insert(
1248 call_id,
1249 LiveToolStatus {
1250 activity: "read_file…".to_string(),
1251 tokens: 12_300,
1252 },
1253 );
1254
1255 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1258 assert!(
1259 live.is_empty(),
1260 "a pending agent call must not synthesize a transcript row"
1261 );
1262 let (rows, override_text, bg_available) = agent_panel_data(&state);
1263 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1264 assert!(bg_available, "agents are detachable via ctrl+b");
1265 assert_eq!(rows.len(), 1);
1266 assert_eq!(rows[0].description, "explore crates");
1267 assert_eq!(rows[0].activity, "read_file…");
1268 assert_eq!(rows[0].tokens, 12_300);
1269 assert!(!rows[0].backgrounded);
1270 }
1271
1272 #[test]
1273 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1274 use mermaid_domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1275
1276 let mut state = mock_state();
1277 let exec_id = ToolCallId(8);
1278 let agent_id = ToolCallId(9);
1279 let call = |id, name: &str, args| PendingToolCall {
1280 call_id: id,
1281 source: mermaid_model::models::tool_call::ToolCall {
1282 id: None,
1283 function: mermaid_model::models::tool_call::FunctionCall {
1284 name: name.to_string(),
1285 arguments: args,
1286 },
1287 },
1288 };
1289 state.turn = TurnState::ExecutingTools {
1290 id: TurnId(1),
1291 started: std::time::SystemTime::now(),
1292 calls: vec![
1293 call(
1294 exec_id,
1295 "execute_command",
1296 serde_json::json!({"command": "cargo test"}),
1297 ),
1298 call(
1299 agent_id,
1300 "agent",
1301 serde_json::json!({"description": "audit docs"}),
1302 ),
1303 ],
1304 outcomes: vec![None, None],
1305 };
1306 state.ui.live_tool_status.insert(
1307 exec_id,
1308 LiveToolStatus {
1309 activity: String::new(),
1310 tokens: 0,
1311 },
1312 );
1313
1314 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1317 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1318 let actions = &live[0].actions;
1319 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1320 assert_eq!(actions[0].action_type, "Bash");
1321 assert_eq!(actions[0].target, "cargo test");
1322 assert!(matches!(
1323 actions[0].result,
1324 mermaid_domain::ActionResult::Running
1325 ));
1326 let (rows, override_text, _) = agent_panel_data(&state);
1327 assert_eq!(override_text, None);
1328 assert_eq!(rows.len(), 1);
1329 }
1330
1331 #[test]
1332 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1333 use mermaid_domain::{GenPhase, TurnId};
1334 use mermaid_model::models::ChatMessage;
1335 use std::borrow::Cow;
1336 use std::time::SystemTime;
1337
1338 let committed = vec![ChatMessage::user("hi")];
1339 let now = chrono::Local::now();
1340
1341 let idle = build_live_messages(&committed, &TurnState::Idle, now);
1343 assert!(matches!(idle, Cow::Borrowed(_)));
1344 assert_eq!(idle.len(), 1);
1345
1346 let turn = TurnState::Generating {
1349 id: TurnId(1),
1350 started: SystemTime::now(),
1351 partial_text: "draft".to_string(),
1352 partial_reasoning: String::new(),
1353 tokens: 0,
1354 phase: GenPhase::Sending,
1355 provider_continuation: None,
1356 pending_tool_calls: Vec::new(),
1357 continuation: false,
1358 };
1359 let live = build_live_messages(&committed, &turn, now);
1360 assert!(matches!(live, Cow::Owned(_)));
1361 assert_eq!(live.len(), 2);
1362 assert_eq!(live[1].timestamp, now);
1363 }
1364
1365 fn kinded(
1366 mut msg: mermaid_model::models::ChatMessage,
1367 kind: mermaid_model::models::ChatMessageKind,
1368 ) -> mermaid_model::models::ChatMessage {
1369 msg.kind = kind;
1370 msg
1371 }
1372
1373 #[test]
1374 fn stitch_committed_merges_chain_and_hides_nudges() {
1375 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1376 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1377 part1.thinking = Some("first trace".to_string());
1378 let mut part2 = kinded(
1380 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1381 ChatMessageKind::Continuation,
1382 );
1383 part2.thinking = Some("second trace".to_string());
1384 let committed = vec![
1385 ChatMessage::user("audit the widget"),
1386 part1,
1387 kinded(
1388 ChatMessage::system("resume nudge"),
1389 ChatMessageKind::RecoveryNudge,
1390 ),
1391 part2,
1392 ];
1393
1394 assert!(needs_stitch(&committed, &TurnState::Idle));
1395 let stitched = stitch_committed(&committed);
1396 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1397 assert_eq!(
1398 stitched[1].content,
1399 "The audit found three issues in the resolver, and here is the fix.",
1400 "contents merge with the resume echo trimmed"
1401 );
1402 assert_eq!(
1403 stitched[1].thinking.as_deref(),
1404 Some("first trace\n\nsecond trace"),
1405 "both reasoning segments survive in order"
1406 );
1407 assert!(
1408 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1409 "nudges never render"
1410 );
1411 }
1412
1413 #[test]
1416 fn context_markers_are_hidden_from_the_transcript() {
1417 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1418 let committed = vec![
1419 ChatMessage::user("plan this"),
1420 kinded(
1421 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1422 ChatMessageKind::ContextMarker,
1423 ),
1424 ChatMessage::assistant("Grounding first."),
1425 ];
1426 assert!(
1431 !needs_stitch(&committed, &TurnState::Idle),
1432 "a marker alone must not defeat the zero-copy path",
1433 );
1434 let stitched = stitch_committed(&committed);
1436 assert_eq!(stitched.len(), 2, "user + assistant only");
1437 assert!(
1438 !stitched
1439 .iter()
1440 .any(|m| m.content.contains("Plan mode is now ON")),
1441 "markers never render"
1442 );
1443 }
1444
1445 #[test]
1446 fn stitch_refuses_non_bubble_predecessor() {
1447 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1448 let committed = vec![
1452 kinded(
1453 ChatMessage::assistant("checkpoint summary"),
1454 ChatMessageKind::ContextCheckpoint,
1455 ),
1456 kinded(
1457 ChatMessage::assistant("orphaned continuation"),
1458 ChatMessageKind::Continuation,
1459 ),
1460 ];
1461 let stitched = stitch_committed(&committed);
1462 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1463 assert_eq!(stitched[1].content, "orphaned continuation");
1464 }
1465
1466 #[test]
1467 fn needs_stitch_is_false_for_plain_sessions() {
1468 use mermaid_model::models::ChatMessage;
1469 let committed = vec![
1472 ChatMessage::user("hi"),
1473 ChatMessage::assistant("hello"),
1474 ChatMessage::system("note"),
1475 ];
1476 assert!(!needs_stitch(&committed, &TurnState::Idle));
1477 }
1478
1479 #[test]
1487 fn a_live_continuation_still_forces_the_stitch() {
1488 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1489 let committed = vec![
1490 ChatMessage::user("write it"),
1491 ChatMessage::assistant("first half"),
1492 kinded(
1493 ChatMessage::system("output limit — continuing"),
1494 ChatMessageKind::RecoveryNudge,
1495 ),
1496 ];
1497 let streaming = TurnState::Generating {
1498 id: mermaid_domain::TurnId(1),
1499 started: std::time::SystemTime::UNIX_EPOCH,
1500 partial_text: "first half and the rest".to_string(),
1501 partial_reasoning: String::new(),
1502 tokens: 0,
1503 phase: mermaid_domain::GenPhase::Streaming,
1504 provider_continuation: None,
1505 pending_tool_calls: Vec::new(),
1506 continuation: true,
1507 };
1508 assert!(
1509 needs_stitch(&committed, &streaming),
1510 "a live continuation needs the nudge stripped to find its bubble",
1511 );
1512 let stitched = stitch_committed(&committed);
1514 assert!(
1515 stitched.last().is_some_and(mergeable_into),
1516 "the stitched tail is the assistant bubble the partial merges into",
1517 );
1518 }
1519
1520 #[test]
1521 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1522 use mermaid_domain::{GenPhase, TurnId};
1523 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1524
1525 let committed = vec![ChatMessage::assistant(
1526 "the fix lands in the resolver module",
1527 )];
1528 let turn = TurnState::Generating {
1529 id: TurnId(2),
1530 started: std::time::SystemTime::now(),
1531 partial_text: "in the resolver module, specifically the clamp".to_string(),
1532 partial_reasoning: String::new(),
1533 tokens: 0,
1534 phase: GenPhase::Streaming,
1535 provider_continuation: None,
1536 pending_tool_calls: Vec::new(),
1537 continuation: true,
1538 };
1539 let live = build_live_messages(&committed, &turn, chrono::Local::now());
1540 let streamed = live.last().expect("pseudo-message appended");
1541 assert_eq!(
1542 streamed.kind,
1543 ChatMessageKind::Continuation,
1544 "the live half is stamped so the widget draws it prefix-less"
1545 );
1546 assert_eq!(
1547 streamed.content, ", specifically the clamp",
1548 "the leading resume echo is trimmed against the committed tail"
1549 );
1550 }
1551
1552 #[test]
1553 fn auto_continued_reply_renders_as_one_bubble() {
1554 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1555 let mut s = mock_state();
1556 s.session.append(ChatMessage::user("audit"), s.now);
1557 s.session
1558 .append(ChatMessage::assistant("part one of the reply"), s.now);
1559 s.session.append(
1560 kinded(
1561 ChatMessage::system("output limit — continuing"),
1562 ChatMessageKind::RecoveryNudge,
1563 ),
1564 s.now,
1565 );
1566 s.session.append(
1567 kinded(
1568 ChatMessage::assistant("and part two lands here"),
1569 ChatMessageKind::Continuation,
1570 ),
1571 s.now,
1572 );
1573
1574 let out = render_to_string(&s);
1575 assert!(out.contains("part one of the reply"));
1576 assert!(out.contains("and part two lands here"));
1577 assert!(
1578 !out.contains("continuing"),
1579 "the recovery nudge never renders"
1580 );
1581 assert_eq!(
1582 out.matches('●').count(),
1583 1,
1584 "both halves share one assistant bullet:\n{out}"
1585 );
1586 }
1587
1588 #[test]
1589 fn streaming_continuation_renders_without_fresh_bullet() {
1590 use mermaid_domain::{GenPhase, TurnId};
1591 use mermaid_model::models::{ChatMessage, ChatMessageKind};
1592 let mut s = mock_state();
1593 s.session.append(ChatMessage::user("audit"), s.now);
1594 s.session
1595 .append(ChatMessage::assistant("part one of the reply"), s.now);
1596 s.session.append(
1597 kinded(
1598 ChatMessage::system("output limit — continuing"),
1599 ChatMessageKind::RecoveryNudge,
1600 ),
1601 s.now,
1602 );
1603 s.turn = TurnState::Generating {
1604 id: TurnId(3),
1605 started: std::time::SystemTime::now(),
1606 partial_text: "and part two streams in".to_string(),
1607 partial_reasoning: String::new(),
1608 tokens: 0,
1609 phase: GenPhase::Streaming,
1610 provider_continuation: None,
1611 pending_tool_calls: Vec::new(),
1612 continuation: true,
1613 };
1614
1615 let out = render_to_string(&s);
1616 assert!(out.contains("part one of the reply"));
1617 assert!(out.contains("and part two streams in"));
1618 assert!(!out.contains("continuing"), "live nudge hidden too");
1619 assert_eq!(
1620 out.matches('●').count(),
1621 1,
1622 "the streaming half joins the committed bubble:\n{out}"
1623 );
1624 }
1625
1626 #[test]
1627 fn user_prompt_renders_with_highlight_band() {
1628 let mut s = mock_state();
1629 s.session.append(
1630 mermaid_model::models::ChatMessage::user("hello there"),
1631 s.now,
1632 );
1633 let buf = render_to_buffer(&s);
1634 let band_bg = crate::render::theme::Theme::dark()
1635 .colors
1636 .user_message_background
1637 .to_color();
1638 let y = (0..buf.area.height)
1640 .find(|&y| {
1641 (0..buf.area.width)
1642 .map(|x| buf[(x, y)].symbol())
1643 .collect::<String>()
1644 .contains("hello there")
1645 })
1646 .expect("user prompt should render");
1647 let banded = (0..buf.area.width)
1650 .filter(|&x| buf[(x, y)].bg == band_bg)
1651 .count();
1652 assert!(
1653 banded >= (buf.area.width as usize) * 3 / 4,
1654 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1655 buf.area.width
1656 );
1657 }
1658
1659 #[test]
1660 fn idle_state_renders_cwd_and_model_footer() {
1661 let s = mock_state();
1662 let frame = render_to_string(&s);
1663 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1665 assert!(frame.contains("ollama/test"));
1666 }
1667
1668 #[test]
1669 fn status_line_appears_during_generating() {
1670 let mut s = mock_state();
1671 s.turn = mermaid_domain::transition::start_generating(
1672 mermaid_domain::TurnId(1),
1673 std::time::SystemTime::now(),
1674 );
1675 let frame = render_to_string(&s);
1676 assert!(
1677 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1678 "expected generation status in frame"
1679 );
1680 }
1681
1682 #[test]
1683 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1684 use mermaid_domain::PendingToolCall;
1685 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1686 let mut s = mock_state();
1687 let call = PendingToolCall {
1688 call_id: mermaid_domain::ToolCallId(1),
1689 source: ModelToolCall {
1690 id: Some("c1".to_string()),
1691 function: FunctionCall {
1692 name: "execute_command".to_string(),
1693 arguments: serde_json::json!({"command": "npm run dev"}),
1694 },
1695 },
1696 };
1697 s.turn = TurnState::ExecutingTools {
1698 id: mermaid_domain::TurnId(1),
1699 started: std::time::SystemTime::now(),
1700 calls: vec![call],
1701 outcomes: vec![None],
1702 };
1703 let frame = render_to_string(&s);
1704 assert!(frame.contains("Running tools..."), "got: {frame}");
1707 assert!(
1708 !frame.contains("Running tools:"),
1709 "status line must not carry tool detail; got: {frame}"
1710 );
1711 assert!(
1713 frame.contains("npm run dev"),
1714 "transcript must show the in-flight call's action row; got: {frame}"
1715 );
1716 }
1717
1718 #[test]
1719 fn pending_question_and_agent_calls_get_no_transcript_row() {
1720 use mermaid_domain::PendingToolCall;
1721 use mermaid_model::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1722 let mut s = mock_state();
1723 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1724 call_id: mermaid_domain::ToolCallId(id),
1725 source: ModelToolCall {
1726 id: Some(format!("c{id}")),
1727 function: FunctionCall {
1728 name: name.to_string(),
1729 arguments: args,
1730 },
1731 },
1732 };
1733 s.turn = TurnState::ExecutingTools {
1734 id: mermaid_domain::TurnId(1),
1735 started: std::time::SystemTime::now(),
1736 calls: vec![
1737 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1738 mk(
1739 2,
1740 "agent",
1741 serde_json::json!({"description": "scan the repo"}),
1742 ),
1743 ],
1744 outcomes: vec![None, None],
1745 };
1746 let frame = render_to_string(&s);
1747 assert!(
1750 !frame.contains("ask_user_question"),
1751 "pending question must not surface as a transcript row or status text; got: {frame}"
1752 );
1753 }
1754
1755 #[test]
1756 fn status_line_appears_during_tool_execution_and_shows_queue() {
1757 let mut s = mock_state();
1758 s.turn = TurnState::ExecutingTools {
1759 id: mermaid_domain::TurnId(1),
1760 started: std::time::SystemTime::now(),
1761 calls: Vec::new(),
1762 outcomes: Vec::new(),
1763 };
1764 s.ui.queued_messages
1765 .push_back(mermaid_domain::QueuedMessage {
1766 text: "please steer this".to_string(),
1767 attachment_ids: Vec::new(),
1768 });
1769 let frame = render_to_string(&s);
1770 assert!(frame.contains("Running tools"), "expected tool status");
1771 assert!(
1772 frame.contains("please steer this"),
1773 "queued busy input must be visible"
1774 );
1775 }
1776
1777 #[test]
1778 fn reasoning_blocks_are_collapsed_by_default() {
1779 let mut s = mock_state();
1780 let mut first_msg = mermaid_model::models::ChatMessage::assistant("first visible answer");
1781 first_msg.thinking = Some("first private chain of thought".to_string());
1782 s.session.append(first_msg, s.now);
1783 let mut second_msg = mermaid_model::models::ChatMessage::assistant("second visible answer");
1784 second_msg.thinking = Some("second private chain of thought".to_string());
1785 s.session.append(second_msg, s.now);
1786 let frame = render_to_string(&s);
1787 assert!(!frame.contains("Reasoning hidden"));
1789 assert!(frame.contains("first visible answer"));
1790 assert!(frame.contains("second visible answer"));
1791 assert!(!frame.contains("first private chain of thought"));
1792 assert!(!frame.contains("second private chain of thought"));
1793 }
1794
1795 #[test]
1799 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1800 let mut s = mock_state();
1801 let mut msg = mermaid_model::models::ChatMessage::assistant("");
1802 msg.thinking = Some("private chain of thought".to_string());
1803 msg.actions.push(mermaid_domain::ActionDisplay {
1804 action_type: "Bash".to_string(),
1805 target: "dir".to_string(),
1806 result: mermaid_domain::ActionResult::Success {
1807 output: "ok".to_string(),
1808 images: None,
1809 },
1810 details: mermaid_domain::ActionDetails::Simple,
1811 duration_seconds: Some(0.015),
1812 metadata: None,
1813 });
1814 s.session.append(msg, s.now);
1815 let frame = render_to_string(&s);
1816 assert!(
1817 !frame.contains("Reasoning hidden"),
1818 "no reasoning-hidden placeholder"
1819 );
1820 assert!(
1821 frame.contains("Bash"),
1822 "the action still renders even though reasoning is hidden"
1823 );
1824 }
1825
1826 #[test]
1827 fn committed_message_appears_in_chat_pane() {
1828 let mut s = mock_state();
1829 s.session.append(
1830 mermaid_model::models::ChatMessage::user("unique-user-token-xyz"),
1831 s.now,
1832 );
1833 let frame = render_to_string(&s);
1834 assert!(frame.contains("unique-user-token-xyz"));
1835 }
1836
1837 #[test]
1838 fn palette_renders_when_input_starts_with_slash() {
1839 let mut s = mock_state();
1840 s.ui.input_buffer = "/help".to_string();
1841 s.ui.input_cursor = 5;
1842 let frame = render_to_string(&s);
1843 assert!(frame.contains("help"));
1845 }
1846
1847 #[test]
1848 fn status_line_helper_maps_idle_to_idle() {
1849 assert_eq!(
1850 GenerationStatus::from_turn(&TurnState::Idle),
1851 GenerationStatus::Idle
1852 );
1853 }
1854}