1pub mod diff;
18pub mod markdown;
19pub mod theme;
20pub mod widgets;
21
22use ratatui::{Frame, layout::Margin};
23use rustc_hash::FxHashMap;
24use unicode_width::UnicodeWidthChar;
25
26use crate::domain::{State, TurnState};
27use crate::models::{ReasoningCapability, ReasoningLevel, nearest_effort};
28
29use widgets::{
30 ChatState, ChatWidget, GenerationStatus, InputState, InputWidget, SlashPaletteWidget,
31 StatusWidget, build_status_lines,
32};
33
34pub struct RenderCache {
43 pub chat: ChatState,
44 pub wrapped_line_cache: FxHashMap<u64, Vec<ratatui::text::Line<'static>>>,
48 stitched: Option<StitchedMemo>,
54 pub theme: theme::Theme,
55 applied_theme: Option<(crate::app::ThemeChoice, bool)>,
61 pub hostname: String,
65 pub username: String,
66 pub version: String,
70 last_mouse_scroll_accum: i32,
74 last_scroll_to_bottom_seq: u32,
77}
78
79impl Default for RenderCache {
80 fn default() -> Self {
81 Self {
82 chat: ChatState::new(),
83 wrapped_line_cache: FxHashMap::default(),
84 theme: theme::Theme::dark(),
85 hostname: std::env::var("HOSTNAME")
86 .or_else(|_| std::env::var("HOST"))
87 .unwrap_or_else(|_| "localhost".to_string()),
88 username: std::env::var("USER")
89 .or_else(|_| std::env::var("USERNAME"))
90 .unwrap_or_else(|_| "user".to_string()),
91 version: env!("CARGO_PKG_VERSION").to_string(),
92 stitched: None,
93 applied_theme: None,
94 last_mouse_scroll_accum: 0,
95 last_scroll_to_bottom_seq: 0,
96 }
97 }
98}
99
100struct StitchedMemo {
102 key: u64,
103 messages: Vec<crate::models::ChatMessage>,
104}
105
106impl RenderCache {
107 pub fn new() -> Self {
108 Self::default()
109 }
110}
111
112pub fn render(state: &State, rstate: &mut RenderCache, frame: &mut Frame) {
114 let want = (state.ui.theme, state.ui.no_color);
117 if rstate.applied_theme != Some(want) {
118 rstate.theme = if state.ui.no_color {
119 theme::Theme::plain()
120 } else {
121 match state.ui.theme {
122 crate::app::ThemeChoice::Dark => theme::Theme::dark(),
123 crate::app::ThemeChoice::Light => theme::Theme::light(),
124 }
125 };
126 rstate.wrapped_line_cache.clear();
129 rstate.applied_theme = Some(want);
130 }
131
132 let pending = state.ui.mouse_scroll_accum - rstate.last_mouse_scroll_accum;
137 if pending > 0 {
138 rstate.chat.scroll_up(pending as u16);
139 } else if pending < 0 {
140 rstate.chat.scroll_down((-pending) as u16);
141 }
142 rstate.last_mouse_scroll_accum = state.ui.mouse_scroll_accum;
143 if state.ui.scroll_to_bottom_seq != rstate.last_scroll_to_bottom_seq {
145 rstate.chat.resume_auto_scroll();
146 rstate.last_scroll_to_bottom_seq = state.ui.scroll_to_bottom_seq;
147 }
148
149 let approval_item = state.pending_approval.front();
152 let question_item = if approval_item.is_none() {
153 state.pending_question.front()
154 } else {
155 None
156 };
157 let question_modal_open = question_item.is_some();
162
163 let terminal_width = frame.area().width.saturating_sub(4) as usize;
165 let input_lines = if state.ui.input_buffer.is_empty() {
166 1
167 } else {
168 let mut lines = 1usize;
169 let mut col = 0usize;
170 for ch in state.ui.input_buffer.chars() {
171 let w = ch.width().unwrap_or(0);
172 if ch == '\n' || col >= terminal_width {
173 lines += 1;
174 col = if ch == '\n' { 0 } else { w };
175 } else {
176 col += w;
177 }
178 }
179 lines.min(5)
180 };
181 let input_height = if question_modal_open {
182 0
183 } else {
184 (input_lines + 2) as u16
185 };
186
187 let status_lines = if question_modal_open {
192 Vec::new()
193 } else if state.is_busy() {
194 let now_sys = std::time::SystemTime::from(state.now);
198 let elapsed_since =
199 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
200 let elapsed_secs = match &state.turn {
201 TurnState::Generating { started, .. } | TurnState::ExecutingTools { started, .. } => {
204 state
205 .runtime
206 .run_started
207 .map_or_else(|| elapsed_since(*started), elapsed_since)
208 },
209 TurnState::Compacting { started, .. } => elapsed_since(*started),
210 TurnState::Cancelling { since, .. } => elapsed_since(*since),
211 TurnState::Idle => 0,
212 };
213 let (agent_rows, status_override, bg_available) = agent_panel_data(state);
214 let task_headline = state
218 .session
219 .conversation
220 .tasks
221 .active()
222 .map(|t| t.active_form.clone());
223 let committed = state.runtime.run_tokens;
232 let live_child_tokens: usize = state.ui.live_tool_status.values().map(|l| l.tokens).sum();
233 let (tokens_display, tokens_estimated) = match &state.turn {
234 TurnState::Generating { tokens, .. } => (committed.output_tokens + *tokens, true),
235 TurnState::ExecutingTools { .. } => (
236 committed.output_tokens + live_child_tokens,
237 committed.contains_estimate || live_child_tokens > 0,
238 ),
239 _ => (0, false),
240 };
241 build_status_lines(
242 GenerationStatus::from_turn(&state.turn),
243 elapsed_secs,
244 tokens_display,
245 tokens_estimated,
246 status_override.as_deref(),
247 &agent_rows,
248 bg_available,
249 task_headline.as_deref(),
250 &state.ui.queued_messages,
251 exit_armed(state),
252 &rstate.theme,
253 frame.area().width.saturating_sub(2),
255 )
256 } else if !state.runtime.background_agents.is_empty() {
257 let (agent_rows, _, _) = agent_panel_data(state);
260 build_status_lines(
261 GenerationStatus::Idle,
262 0,
263 0,
264 false,
265 None,
266 &agent_rows,
267 false,
268 None,
269 &state.ui.queued_messages,
270 exit_armed(state),
271 &rstate.theme,
272 frame.area().width.saturating_sub(2),
273 )
274 } else {
275 Vec::new()
276 };
277
278 let status_reserve = 10 + input_height + 2;
283 let status_line_height = (status_lines.len() as u16)
284 .min(14)
285 .min(frame.area().height.saturating_sub(status_reserve));
286
287 let tasks_store = &state.session.conversation.tasks;
292 let tasks_attached = status_line_height > 0;
293 let tasks_zone_height = if question_modal_open {
294 0
295 } else if widgets::tasks_visible(
296 tasks_store,
297 &state.turn,
298 state.ui.tasks_collapsed,
299 tasks_attached,
300 ) {
301 widgets::tasks_height(tasks_store, state.ui.tasks_collapsed).min(
302 frame
303 .area()
304 .height
305 .saturating_sub(status_reserve + status_line_height),
306 )
307 } else {
308 0
309 };
310
311 let confirm_open =
321 approval_item.is_none() && question_item.is_none() && state.confirm.is_some();
322 let conv_list_open = approval_item.is_none()
323 && question_item.is_none()
324 && !confirm_open
325 && matches!(
326 state.ui.mode,
327 crate::domain::UiMode::ConversationList { .. }
328 );
329 let rewind_open = approval_item.is_none()
330 && question_item.is_none()
331 && !confirm_open
332 && matches!(state.ui.mode, crate::domain::UiMode::RewindPicker { .. });
333 let plan_config_open = approval_item.is_none()
334 && question_item.is_none()
335 && !confirm_open
336 && matches!(state.ui.mode, crate::domain::UiMode::PlanConfig { .. });
337 let file_picker_open = approval_item.is_none()
338 && question_item.is_none()
339 && !confirm_open
340 && !conv_list_open
341 && !rewind_open
342 && !plan_config_open
343 && state.ui.file_picker_open();
344 let palette_open = approval_item.is_none()
345 && question_item.is_none()
346 && !confirm_open
347 && !conv_list_open
348 && !file_picker_open
349 && state.ui.input_buffer.starts_with('/');
350 let bottom_height = if let Some(item) = approval_item {
351 let body_lines = item.prompt.lines().count().clamp(1, 6) as u16;
353 2 + body_lines + 1 + 3
354 } else if let Some(qset) = question_item {
355 widgets::question_modal_height(qset, &rstate.theme)
356 } else if confirm_open {
357 6
358 } else if conv_list_open || rewind_open {
359 12
360 } else if plan_config_open {
361 widgets::PLAN_CONFIG_HEIGHT
362 } else if file_picker_open {
363 let rows = state.ui.file_picker_matches.len().clamp(1, 8);
364 (rows as u16) + 2
365 } else if palette_open {
366 let typed = state
367 .ui
368 .input_buffer
369 .trim_start_matches('/')
370 .split_whitespace()
371 .next()
372 .unwrap_or("");
373 let row_count =
374 crate::domain::slash_commands::filter_entries(typed, &state.plugin_commands)
375 .len()
376 .clamp(1, 8);
377 (row_count as u16) + 2
378 } else {
379 2
380 };
381
382 use ratatui::layout::{Constraint, Direction, Layout};
386 let chunks = Layout::default()
387 .direction(Direction::Vertical)
388 .constraints([
389 Constraint::Min(10),
390 Constraint::Length(status_line_height),
391 Constraint::Length(tasks_zone_height),
392 Constraint::Length(input_height),
393 Constraint::Length(bottom_height),
394 ])
395 .split(frame.area());
396
397 let chat_area = chunks[0].inner(Margin {
399 horizontal: 1,
400 vertical: 0,
401 });
402 let committed = state.session.messages();
407 let base: &[crate::models::ChatMessage] = if needs_stitch(committed, &state.turn) {
408 let key = stitch_fingerprint(committed);
409 if rstate.stitched.as_ref().map(|m| m.key) != Some(key) {
410 rstate.stitched = Some(StitchedMemo {
411 key,
412 messages: stitch_committed(committed),
413 });
414 }
415 &rstate
416 .stitched
417 .as_ref()
418 .expect("stitched memo populated above")
419 .messages
420 } else {
421 committed
422 };
423 let live_messages = build_live_messages(base, &state.turn, state.now);
424 let blink_on = (state.now.timestamp_millis().div_euclid(500)) % 2 == 0;
427 let chat_widget = ChatWidget {
428 messages: live_messages.as_ref(),
429 content_key: chat_content_key(state, base, live_messages.as_ref(), blink_on),
430 theme: &rstate.theme,
431 wrapped_line_cache: &mut rstate.wrapped_line_cache,
432 show_reasoning: state.ui.show_reasoning,
433 blink_on,
434 };
435 frame.render_stateful_widget(chat_widget, chat_area, &mut rstate.chat);
436
437 if !status_lines.is_empty() {
440 let status_area = chunks[1].inner(Margin {
441 horizontal: 1,
442 vertical: 0,
443 });
444 frame.render_widget(ratatui::widgets::Paragraph::new(status_lines), status_area);
445 }
446
447 if tasks_zone_height > 0 {
449 let tasks_area = chunks[2].inner(Margin {
450 horizontal: 1,
451 vertical: 0,
452 });
453 let lines = widgets::build_task_lines(
454 tasks_store,
455 state.ui.tasks_collapsed,
456 tasks_attached,
457 tasks_area.width,
458 &rstate.theme,
459 );
460 frame.render_widget(ratatui::widgets::Paragraph::new(lines), tasks_area);
461 }
462
463 if !question_modal_open {
467 let input_widget = InputWidget {
468 input: state.ui.input_buffer.as_str(),
469 showing_command_hints: state.ui.input_buffer.starts_with('/'),
470 theme: &rstate.theme,
471 reasoning_active: state.session.reasoning != ReasoningLevel::None,
472 exit_armed: exit_armed(state),
473 rewind_armed: rewind_armed(state),
474 };
475 let mut input_widget_state = InputState {
476 cursor_position: state.ui.input_cursor.min(state.ui.input_buffer.len()),
477 };
478 frame.render_stateful_widget(input_widget, chunks[3], &mut input_widget_state);
479
480 let input_area = chunks[3];
482 let content_width = input_area.width.saturating_sub(2) as usize;
483 let (cursor_row, cursor_col) = InputState::calculate_cursor_position(
484 &state.ui.input_buffer,
485 state.ui.input_cursor.min(state.ui.input_buffer.len()),
486 content_width,
487 );
488 frame.set_cursor_position((input_area.x + cursor_col + 2, input_area.y + 1 + cursor_row));
489 }
490
491 let requested = state.session.reasoning;
495 let effective = match supported_reasoning_for(state) {
496 Some(ReasoningCapability::Levels(supp)) => {
497 nearest_effort(requested, &supp).unwrap_or(requested)
498 },
499 _ => requested,
500 };
501 let requested_level = if effective == requested {
502 None
503 } else {
504 Some(requested)
505 };
506
507 if let Some(item) = state.pending_approval.front() {
510 use widgets::ApprovalModalWidget;
511 let options = if item.allowlist_scope.is_empty() {
515 vec!["1. Yes".to_string(), "2. No (Esc)".to_string()]
516 } else {
517 vec![
518 "1. Yes".to_string(),
519 format!("2. Yes, and don't ask again for `{}`", item.allowlist_scope),
520 "3. No (Esc)".to_string(),
521 ]
522 };
523 let widget = ApprovalModalWidget {
524 theme: &rstate.theme,
525 title: format!("Approval required — {} [{}]", item.tool, item.risk),
526 body: item.prompt.as_str(),
527 options,
528 selected_index: Some(item.selected_option),
529 accent: rstate.theme.colors.warning.to_color(),
530 };
531 frame.render_widget(widget, chunks[4]);
532 } else if let Some(qset) = state.pending_question.front() {
533 use widgets::QuestionModalWidget;
534 let widget = QuestionModalWidget {
535 theme: &rstate.theme,
536 set: qset,
537 };
538 frame.render_widget(widget, chunks[4]);
539 } else if let Some(confirm) = &state.confirm {
540 use widgets::ApprovalModalWidget;
541 let widget = ApprovalModalWidget {
542 theme: &rstate.theme,
543 title: "Confirm".to_string(),
544 body: confirm.prompt.as_str(),
545 options: vec!["y. Yes".to_string(), "n. No (Esc)".to_string()],
546 selected_index: None,
547 accent: rstate.theme.colors.warning.to_color(),
548 };
549 frame.render_widget(widget, chunks[4]);
550 } else if let crate::domain::UiMode::ConversationList { candidates, cursor } = &state.ui.mode {
551 use widgets::ConversationListWidget;
552 let widget = ConversationListWidget {
553 theme: &rstate.theme,
554 candidates,
555 cursor: *cursor,
556 };
557 frame.render_widget(widget, chunks[4]);
558 } else if let crate::domain::UiMode::RewindPicker { candidates, cursor } = &state.ui.mode {
559 use widgets::RewindPickerWidget;
560 let widget = RewindPickerWidget {
561 theme: &rstate.theme,
562 candidates,
563 cursor: *cursor,
564 };
565 frame.render_widget(widget, chunks[4]);
566 } else if let crate::domain::UiMode::PlanConfig { cursor } = &state.ui.mode {
567 use widgets::PlanConfigWidget;
568 let widget = PlanConfigWidget {
569 theme: &rstate.theme,
570 plan: &state.settings.plan,
571 session_model: &state.session.model_id,
572 cursor: *cursor,
573 };
574 frame.render_widget(widget, chunks[4]);
575 } else if file_picker_open {
576 use widgets::FilePickerWidget;
577 let widget = FilePickerWidget {
578 theme: &rstate.theme,
579 matches: &state.ui.file_picker_matches,
580 selected_index: state.ui.file_picker_cursor.unwrap_or(0),
581 loading: state.ui.project_files_loading && state.ui.project_files.is_none(),
582 };
583 frame.render_widget(widget, chunks[4]);
584 } else if palette_open {
585 let typed = state
586 .ui
587 .input_buffer
588 .trim_start_matches('/')
589 .split_whitespace()
590 .next()
591 .unwrap_or("");
592 let entries = crate::domain::slash_commands::filter_entries(typed, &state.plugin_commands);
593 let palette_widget = SlashPaletteWidget {
594 theme: &rstate.theme,
595 entries,
596 selected_index: state.ui.palette_cursor.unwrap_or(0),
597 };
598 frame.render_widget(palette_widget, chunks[4]);
599 } else {
600 let cwd = state.cwd.display().to_string();
601 let status_widget = StatusWidget {
602 theme: &rstate.theme,
603 working_dir: &cwd,
604 hostname: &rstate.hostname,
605 username: &rstate.username,
606 version: &rstate.version,
607 context_usage: state.session.context_usage.as_ref(),
608 model_name: &state.session.model_id,
609 reasoning_level: effective,
610 requested_level,
611 safety_mode: state.session.safety_mode,
612 plan_resume: state
614 .session
615 .plan
616 .as_ref()
617 .filter(|_| state.session.safety_mode.is_planning())
618 .map(|plan| plan.resume_safety_mode),
619 };
620 frame.render_widget(status_widget, chunks[4]);
621 }
622}
623
624pub(crate) fn mergeable_into(prev: &crate::models::ChatMessage) -> bool {
631 prev.role == crate::models::MessageRole::Assistant
632 && matches!(
633 prev.kind,
634 crate::models::ChatMessageKind::Normal | crate::models::ChatMessageKind::Continuation
635 )
636 && prev.tool_calls.is_none()
637}
638
639fn chat_content_key(
659 state: &State,
660 base: &[crate::models::ChatMessage],
661 live: &[crate::models::ChatMessage],
662 blink_on: bool,
663) -> u64 {
664 use std::hash::{Hash, Hasher};
665 let mut h = rustc_hash::FxHasher::default();
666 state.session.conversation.revision().hash(&mut h);
667 base.len().hash(&mut h);
670 for msg in live.iter().skip(base.len()) {
671 msg.content.hash(&mut h);
672 msg.thinking.hash(&mut h);
673 std::mem::discriminant(&msg.kind).hash(&mut h);
674 msg.actions.len().hash(&mut h);
675 for action in &msg.actions {
676 action.action_type.hash(&mut h);
677 action.target.hash(&mut h);
678 std::mem::discriminant(&action.result).hash(&mut h);
679 }
680 }
681 if !matches!(state.turn, TurnState::Idle) {
682 blink_on.hash(&mut h);
683 }
684 h.finish()
685}
686
687fn needs_stitch(committed: &[crate::models::ChatMessage], turn: &TurnState) -> bool {
708 let live_continuation = matches!(
709 turn,
710 TurnState::Generating { continuation, .. } if *continuation
711 );
712 live_continuation
713 || committed
714 .iter()
715 .any(|m| m.kind == crate::models::ChatMessageKind::Continuation)
716}
717
718fn stitch_fingerprint(committed: &[crate::models::ChatMessage]) -> u64 {
724 use std::hash::{Hash, Hasher};
725 use std::mem::discriminant;
726
727 let mut h = rustc_hash::FxHasher::default();
728 committed.len().hash(&mut h);
729 for msg in committed {
730 msg.content.hash(&mut h);
731 msg.thinking.hash(&mut h);
732 msg.timestamp.timestamp().hash(&mut h);
733 msg.images.as_ref().map_or(0, |v| v.len()).hash(&mut h);
734 msg.image_numbers
735 .as_ref()
736 .map_or(0, |v| v.len())
737 .hash(&mut h);
738 discriminant(&msg.role).hash(&mut h);
739 discriminant(&msg.kind).hash(&mut h);
740 msg.tool_calls.as_ref().map(|t| t.len()).hash(&mut h);
741 msg.actions.len().hash(&mut h);
749 for action in &msg.actions {
750 action.action_type.hash(&mut h);
751 action.target.hash(&mut h);
752 discriminant(&action.result).hash(&mut h);
753 discriminant(&action.details).hash(&mut h);
754 action.duration_seconds.map(f64::to_bits).hash(&mut h);
755 if let Some(meta) = &action.metadata {
756 meta.lines_added.hash(&mut h);
757 meta.lines_removed.hash(&mut h);
758 meta.diff_truncated.hash(&mut h);
759 meta.display_diff.as_ref().map(String::len).hash(&mut h);
760 }
761 }
762 }
763 h.finish()
764}
765
766fn stitch_committed(committed: &[crate::models::ChatMessage]) -> Vec<crate::models::ChatMessage> {
778 let mut out: Vec<crate::models::ChatMessage> = Vec::with_capacity(committed.len());
779 for msg in committed {
780 if matches!(
781 msg.kind,
782 crate::models::ChatMessageKind::RecoveryNudge
783 | crate::models::ChatMessageKind::ContextMarker
784 ) {
785 continue;
786 }
787 if msg.kind == crate::models::ChatMessageKind::Continuation
788 && let Some(prev) = out.last_mut()
789 && mergeable_into(prev)
790 {
791 merge_continuation(prev, msg);
792 continue;
793 }
794 out.push(msg.clone());
795 }
796 out
797}
798
799fn merge_continuation(prev: &mut crate::models::ChatMessage, cont: &crate::models::ChatMessage) {
803 let skip = crate::utils::continuation_overlap(&prev.content, &cont.content);
804 prev.content.push_str(&cont.content[skip..]);
805 if let Some(cont_thinking) = &cont.thinking {
806 match &mut prev.thinking {
807 Some(t) => {
808 t.push_str("\n\n");
809 t.push_str(cont_thinking);
810 },
811 None => prev.thinking = Some(cont_thinking.clone()),
812 }
813 }
814 prev.actions.extend(cont.actions.iter().cloned());
815 if let Some(imgs) = &cont.images {
816 prev.images
817 .get_or_insert_with(Vec::new)
818 .extend(imgs.iter().cloned());
819 }
820 if let Some(nums) = &cont.image_numbers {
821 prev.image_numbers
822 .get_or_insert_with(Vec::new)
823 .extend(nums.iter().copied());
824 }
825 if cont.tool_calls.is_some() {
828 prev.tool_calls = cont.tool_calls.clone();
829 }
830}
831
832fn build_live_messages<'a>(
852 committed: &'a [crate::models::ChatMessage],
853 turn: &TurnState,
854 now: chrono::DateTime<chrono::Local>,
855) -> std::borrow::Cow<'a, [crate::models::ChatMessage]> {
856 if let TurnState::ExecutingTools {
857 calls, outcomes, ..
858 } = turn
859 {
860 let actions: Vec<crate::domain::ActionDisplay> = calls
861 .iter()
862 .zip(outcomes)
863 .filter_map(|(call, outcome)| match outcome {
864 Some(outcome) => Some(crate::domain::transition::action_display_for(call, outcome)),
865 None => {
866 let name = call.source.function.name.as_str();
867 if name == "agent" || name == "ask_user_question" {
868 return None;
869 }
870 let (action_type, target) = crate::domain::display_info_for(call);
871 Some(crate::domain::ActionDisplay {
872 action_type,
873 target,
874 result: crate::domain::ActionResult::Running,
875 details: crate::domain::ActionDetails::Simple,
876 duration_seconds: None,
877 metadata: None,
878 })
879 },
880 })
881 .collect();
882 if actions.is_empty() {
883 return std::borrow::Cow::Borrowed(committed);
884 }
885 let mut msg = crate::models::ChatMessage::assistant("");
886 msg.timestamp = now;
887 msg.actions = actions;
888 let mut out = committed.to_vec();
889 out.push(msg);
890 return std::borrow::Cow::Owned(out);
891 }
892 if let TurnState::Generating {
896 partial_text,
897 partial_reasoning,
898 continuation,
899 ..
900 } = turn
901 && (!partial_text.is_empty() || !partial_reasoning.is_empty())
902 {
903 let thinking = if partial_reasoning.is_empty() {
904 None
905 } else {
906 Some(partial_reasoning.clone())
907 };
908 let stitching = *continuation && committed.last().is_some_and(mergeable_into);
909 let content = if stitching {
910 let prev = &committed[committed.len() - 1].content;
911 let skip = crate::utils::continuation_overlap(prev, partial_text);
912 partial_text[skip..].to_string()
913 } else {
914 partial_text.clone()
915 };
916 let msg = crate::models::ChatMessage {
917 role: crate::models::MessageRole::Assistant,
918 content,
919 timestamp: now,
922 kind: if stitching {
923 crate::models::ChatMessageKind::Continuation
924 } else {
925 crate::models::ChatMessageKind::Normal
926 },
927 metadata: None,
928 actions: Vec::new(),
929 thinking,
930 images: None,
931 image_numbers: None,
932 tool_calls: None,
933 tool_call_id: None,
934 tool_name: None,
935 provider_continuation: None,
936 };
937 let mut out = committed.to_vec();
938 out.push(msg);
939 std::borrow::Cow::Owned(out)
940 } else {
941 std::borrow::Cow::Borrowed(committed)
942 }
943}
944
945fn exit_armed(state: &State) -> bool {
949 state
950 .ui
951 .exit_armed_until
952 .is_some_and(|deadline| state.now <= deadline)
953}
954
955fn rewind_armed(state: &State) -> bool {
958 state
959 .ui
960 .esc_armed_at
961 .is_some_and(|armed| (state.now - armed) <= chrono::Duration::milliseconds(1000))
962}
963
964fn agent_panel_data(state: &State) -> (Vec<widgets::AgentPanelRow>, Option<String>, bool) {
969 let now_sys = std::time::SystemTime::from(state.now);
970 let elapsed_since =
971 |t: std::time::SystemTime| now_sys.duration_since(t).map(|d| d.as_secs()).unwrap_or(0);
972
973 let mut rows = Vec::new();
974 let mut running_agents = 0usize;
975 let mut pending_total = 0usize;
976 let mut bg_available = false;
977 if let TurnState::ExecutingTools {
978 calls,
979 outcomes,
980 started,
981 ..
982 } = &state.turn
983 {
984 let elapsed = elapsed_since(*started);
985 for (call, _) in calls.iter().zip(outcomes).filter(|(_, o)| o.is_none()) {
986 pending_total += 1;
987 let name = call.source.function.name.as_str();
988 if name == "execute_command" || name == "agent" {
989 bg_available = true;
990 }
991 if name != "agent" {
992 continue;
993 }
994 running_agents += 1;
995 let (_, description) = crate::domain::display_info_for(call);
996 let live = state.ui.live_tool_status.get(&call.call_id);
997 rows.push(widgets::AgentPanelRow {
998 description,
999 activity: live.map(|l| l.activity.clone()).unwrap_or_default(),
1000 tokens: live.map_or(0, |l| l.tokens),
1001 elapsed_secs: elapsed,
1002 backgrounded: false,
1003 });
1004 }
1005 }
1006 for agent in &state.runtime.background_agents {
1007 rows.push(widgets::AgentPanelRow {
1008 description: agent.description.clone(),
1009 activity: agent.activity.clone(),
1010 tokens: agent.tokens,
1011 elapsed_secs: elapsed_since(agent.started),
1012 backgrounded: true,
1013 });
1014 }
1015 let status_override = (running_agents > 0 && running_agents == pending_total).then(|| {
1016 if running_agents == 1 {
1017 "Running 1 agent".to_string()
1018 } else {
1019 format!("Running {running_agents} agents")
1020 }
1021 });
1022 (rows, status_override, bg_available)
1023}
1024
1025fn supported_reasoning_for(_state: &State) -> Option<ReasoningCapability> {
1030 None
1031}
1032
1033#[cfg(test)]
1038pub(crate) fn render_frame(
1039 state: &State,
1040 rstate: &mut RenderCache,
1041 width: u16,
1042 height: u16,
1043) -> String {
1044 use ratatui::Terminal;
1045 use ratatui::backend::TestBackend;
1046 let backend = TestBackend::new(width, height);
1047 let mut terminal = Terminal::new(backend).expect("terminal");
1048 terminal.draw(|f| render(state, rstate, f)).expect("draw");
1049 let buf = terminal.backend().buffer();
1050 let mut out = String::new();
1051 for y in 0..buf.area.height {
1052 for x in 0..buf.area.width {
1053 out.push_str(buf[(x, y)].symbol());
1054 }
1055 out.push('\n');
1056 }
1057 out
1058}
1059
1060#[cfg(all(test, unix))]
1063mod snapshots;
1064
1065#[cfg(test)]
1067mod bench;
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072 use crate::app::Config;
1073 use crate::domain::{State, TurnState};
1074 use ratatui::Terminal;
1075 use ratatui::backend::TestBackend;
1076 use std::path::PathBuf;
1077
1078 fn mock_state() -> State {
1079 State::new(
1080 Config::default(),
1081 PathBuf::from("/tmp/p"),
1082 "ollama/test".to_string(),
1083 chrono::Local::now(),
1084 )
1085 }
1086
1087 fn render_to_string(state: &State) -> String {
1088 render_frame(state, &mut RenderCache::new(), 80, 24)
1089 }
1090
1091 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1092 let backend = TestBackend::new(80, 24);
1093 let mut terminal = Terminal::new(backend).expect("terminal");
1094 let mut rstate = RenderCache::new();
1095 terminal
1096 .draw(|f| render(state, &mut rstate, f))
1097 .expect("draw");
1098 terminal.backend().buffer().clone()
1099 }
1100
1101 #[test]
1102 fn theme_choice_changes_colors_never_glyphs() {
1103 let mut state = mock_state();
1107 state
1108 .session
1109 .append(crate::models::ChatMessage::user("hello"), state.now);
1110 let dark = render_to_string(&state);
1111 state.ui.theme = crate::app::ThemeChoice::Light;
1112 let light = render_to_string(&state);
1113 assert_eq!(dark, light, "light theme changed glyphs");
1114 state.ui.no_color = true;
1115 let plain = render_to_string(&state);
1116 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1117 }
1118
1119 #[test]
1120 fn theme_memo_swaps_palette_on_state_change() {
1121 let mut state = mock_state();
1122 let mut rstate = RenderCache::new();
1123 render_frame(&state, &mut rstate, 80, 24);
1124 assert_eq!(rstate.theme.name, "Dark");
1125 state.ui.theme = crate::app::ThemeChoice::Light;
1126 render_frame(&state, &mut rstate, 80, 24);
1127 assert_eq!(rstate.theme.name, "Light");
1128 state.ui.no_color = true;
1130 render_frame(&state, &mut rstate, 80, 24);
1131 assert_eq!(rstate.theme.name, "Plain");
1132 }
1133
1134 #[test]
1135 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1136 use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1137
1138 let mut state = mock_state();
1139 let call_id = ToolCallId(7);
1140 state.turn = TurnState::ExecutingTools {
1141 id: TurnId(1),
1142 started: std::time::SystemTime::now(),
1143 calls: vec![PendingToolCall {
1144 call_id,
1145 source: crate::models::tool_call::ToolCall {
1146 id: None,
1147 function: crate::models::tool_call::FunctionCall {
1148 name: "agent".to_string(),
1149 arguments: serde_json::json!({"description": "explore crates"}),
1150 },
1151 },
1152 }],
1153 outcomes: vec![None],
1154 };
1155 state.ui.live_tool_status.insert(
1156 call_id,
1157 LiveToolStatus {
1158 activity: "read_file…".to_string(),
1159 tokens: 12_300,
1160 },
1161 );
1162
1163 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1166 assert!(
1167 live.is_empty(),
1168 "a pending agent call must not synthesize a transcript row"
1169 );
1170 let (rows, override_text, bg_available) = agent_panel_data(&state);
1171 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1172 assert!(bg_available, "agents are detachable via ctrl+b");
1173 assert_eq!(rows.len(), 1);
1174 assert_eq!(rows[0].description, "explore crates");
1175 assert_eq!(rows[0].activity, "read_file…");
1176 assert_eq!(rows[0].tokens, 12_300);
1177 assert!(!rows[0].backgrounded);
1178 }
1179
1180 #[test]
1181 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1182 use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1183
1184 let mut state = mock_state();
1185 let exec_id = ToolCallId(8);
1186 let agent_id = ToolCallId(9);
1187 let call = |id, name: &str, args| PendingToolCall {
1188 call_id: id,
1189 source: crate::models::tool_call::ToolCall {
1190 id: None,
1191 function: crate::models::tool_call::FunctionCall {
1192 name: name.to_string(),
1193 arguments: args,
1194 },
1195 },
1196 };
1197 state.turn = TurnState::ExecutingTools {
1198 id: TurnId(1),
1199 started: std::time::SystemTime::now(),
1200 calls: vec![
1201 call(
1202 exec_id,
1203 "execute_command",
1204 serde_json::json!({"command": "cargo test"}),
1205 ),
1206 call(
1207 agent_id,
1208 "agent",
1209 serde_json::json!({"description": "audit docs"}),
1210 ),
1211 ],
1212 outcomes: vec![None, None],
1213 };
1214 state.ui.live_tool_status.insert(
1215 exec_id,
1216 LiveToolStatus {
1217 activity: String::new(),
1218 tokens: 0,
1219 },
1220 );
1221
1222 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1225 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1226 let actions = &live[0].actions;
1227 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1228 assert_eq!(actions[0].action_type, "Bash");
1229 assert_eq!(actions[0].target, "cargo test");
1230 assert!(matches!(
1231 actions[0].result,
1232 crate::domain::ActionResult::Running
1233 ));
1234 let (rows, override_text, _) = agent_panel_data(&state);
1235 assert_eq!(override_text, None);
1236 assert_eq!(rows.len(), 1);
1237 }
1238
1239 #[test]
1240 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1241 use crate::domain::{GenPhase, TurnId};
1242 use crate::models::ChatMessage;
1243 use std::borrow::Cow;
1244 use std::time::SystemTime;
1245
1246 let committed = vec![ChatMessage::user("hi")];
1247 let now = chrono::Local::now();
1248
1249 let idle = build_live_messages(&committed, &TurnState::Idle, now);
1251 assert!(matches!(idle, Cow::Borrowed(_)));
1252 assert_eq!(idle.len(), 1);
1253
1254 let turn = TurnState::Generating {
1257 id: TurnId(1),
1258 started: SystemTime::now(),
1259 partial_text: "draft".to_string(),
1260 partial_reasoning: String::new(),
1261 tokens: 0,
1262 phase: GenPhase::Sending,
1263 provider_continuation: None,
1264 pending_tool_calls: Vec::new(),
1265 continuation: false,
1266 };
1267 let live = build_live_messages(&committed, &turn, now);
1268 assert!(matches!(live, Cow::Owned(_)));
1269 assert_eq!(live.len(), 2);
1270 assert_eq!(live[1].timestamp, now);
1271 }
1272
1273 fn kinded(
1274 mut msg: crate::models::ChatMessage,
1275 kind: crate::models::ChatMessageKind,
1276 ) -> crate::models::ChatMessage {
1277 msg.kind = kind;
1278 msg
1279 }
1280
1281 #[test]
1282 fn stitch_committed_merges_chain_and_hides_nudges() {
1283 use crate::models::{ChatMessage, ChatMessageKind};
1284 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1285 part1.thinking = Some("first trace".to_string());
1286 let mut part2 = kinded(
1288 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1289 ChatMessageKind::Continuation,
1290 );
1291 part2.thinking = Some("second trace".to_string());
1292 let committed = vec![
1293 ChatMessage::user("audit the widget"),
1294 part1,
1295 kinded(
1296 ChatMessage::system("resume nudge"),
1297 ChatMessageKind::RecoveryNudge,
1298 ),
1299 part2,
1300 ];
1301
1302 assert!(needs_stitch(&committed, &TurnState::Idle));
1303 let stitched = stitch_committed(&committed);
1304 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1305 assert_eq!(
1306 stitched[1].content,
1307 "The audit found three issues in the resolver, and here is the fix.",
1308 "contents merge with the resume echo trimmed"
1309 );
1310 assert_eq!(
1311 stitched[1].thinking.as_deref(),
1312 Some("first trace\n\nsecond trace"),
1313 "both reasoning segments survive in order"
1314 );
1315 assert!(
1316 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1317 "nudges never render"
1318 );
1319 }
1320
1321 #[test]
1324 fn context_markers_are_hidden_from_the_transcript() {
1325 use crate::models::{ChatMessage, ChatMessageKind};
1326 let committed = vec![
1327 ChatMessage::user("plan this"),
1328 kinded(
1329 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1330 ChatMessageKind::ContextMarker,
1331 ),
1332 ChatMessage::assistant("Grounding first."),
1333 ];
1334 assert!(
1339 !needs_stitch(&committed, &TurnState::Idle),
1340 "a marker alone must not defeat the zero-copy path",
1341 );
1342 let stitched = stitch_committed(&committed);
1344 assert_eq!(stitched.len(), 2, "user + assistant only");
1345 assert!(
1346 !stitched
1347 .iter()
1348 .any(|m| m.content.contains("Plan mode is now ON")),
1349 "markers never render"
1350 );
1351 }
1352
1353 #[test]
1354 fn stitch_refuses_non_bubble_predecessor() {
1355 use crate::models::{ChatMessage, ChatMessageKind};
1356 let committed = vec![
1360 kinded(
1361 ChatMessage::assistant("checkpoint summary"),
1362 ChatMessageKind::ContextCheckpoint,
1363 ),
1364 kinded(
1365 ChatMessage::assistant("orphaned continuation"),
1366 ChatMessageKind::Continuation,
1367 ),
1368 ];
1369 let stitched = stitch_committed(&committed);
1370 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1371 assert_eq!(stitched[1].content, "orphaned continuation");
1372 }
1373
1374 #[test]
1375 fn needs_stitch_is_false_for_plain_sessions() {
1376 use crate::models::ChatMessage;
1377 let committed = vec![
1380 ChatMessage::user("hi"),
1381 ChatMessage::assistant("hello"),
1382 ChatMessage::system("note"),
1383 ];
1384 assert!(!needs_stitch(&committed, &TurnState::Idle));
1385 }
1386
1387 #[test]
1395 fn a_live_continuation_still_forces_the_stitch() {
1396 use crate::models::{ChatMessage, ChatMessageKind};
1397 let committed = vec![
1398 ChatMessage::user("write it"),
1399 ChatMessage::assistant("first half"),
1400 kinded(
1401 ChatMessage::system("output limit — continuing"),
1402 ChatMessageKind::RecoveryNudge,
1403 ),
1404 ];
1405 let streaming = TurnState::Generating {
1406 id: crate::domain::TurnId(1),
1407 started: std::time::SystemTime::UNIX_EPOCH,
1408 partial_text: "first half and the rest".to_string(),
1409 partial_reasoning: String::new(),
1410 tokens: 0,
1411 phase: crate::domain::GenPhase::Streaming,
1412 provider_continuation: None,
1413 pending_tool_calls: Vec::new(),
1414 continuation: true,
1415 };
1416 assert!(
1417 needs_stitch(&committed, &streaming),
1418 "a live continuation needs the nudge stripped to find its bubble",
1419 );
1420 let stitched = stitch_committed(&committed);
1422 assert!(
1423 stitched.last().is_some_and(mergeable_into),
1424 "the stitched tail is the assistant bubble the partial merges into",
1425 );
1426 }
1427
1428 #[test]
1429 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1430 use crate::domain::{GenPhase, TurnId};
1431 use crate::models::{ChatMessage, ChatMessageKind};
1432
1433 let committed = vec![ChatMessage::assistant(
1434 "the fix lands in the resolver module",
1435 )];
1436 let turn = TurnState::Generating {
1437 id: TurnId(2),
1438 started: std::time::SystemTime::now(),
1439 partial_text: "in the resolver module, specifically the clamp".to_string(),
1440 partial_reasoning: String::new(),
1441 tokens: 0,
1442 phase: GenPhase::Streaming,
1443 provider_continuation: None,
1444 pending_tool_calls: Vec::new(),
1445 continuation: true,
1446 };
1447 let live = build_live_messages(&committed, &turn, chrono::Local::now());
1448 let streamed = live.last().expect("pseudo-message appended");
1449 assert_eq!(
1450 streamed.kind,
1451 ChatMessageKind::Continuation,
1452 "the live half is stamped so the widget draws it prefix-less"
1453 );
1454 assert_eq!(
1455 streamed.content, ", specifically the clamp",
1456 "the leading resume echo is trimmed against the committed tail"
1457 );
1458 }
1459
1460 #[test]
1461 fn auto_continued_reply_renders_as_one_bubble() {
1462 use crate::models::{ChatMessage, ChatMessageKind};
1463 let mut s = mock_state();
1464 s.session.append(ChatMessage::user("audit"), s.now);
1465 s.session
1466 .append(ChatMessage::assistant("part one of the reply"), s.now);
1467 s.session.append(
1468 kinded(
1469 ChatMessage::system("output limit — continuing"),
1470 ChatMessageKind::RecoveryNudge,
1471 ),
1472 s.now,
1473 );
1474 s.session.append(
1475 kinded(
1476 ChatMessage::assistant("and part two lands here"),
1477 ChatMessageKind::Continuation,
1478 ),
1479 s.now,
1480 );
1481
1482 let out = render_to_string(&s);
1483 assert!(out.contains("part one of the reply"));
1484 assert!(out.contains("and part two lands here"));
1485 assert!(
1486 !out.contains("continuing"),
1487 "the recovery nudge never renders"
1488 );
1489 assert_eq!(
1490 out.matches('●').count(),
1491 1,
1492 "both halves share one assistant bullet:\n{out}"
1493 );
1494 }
1495
1496 #[test]
1497 fn streaming_continuation_renders_without_fresh_bullet() {
1498 use crate::domain::{GenPhase, TurnId};
1499 use crate::models::{ChatMessage, ChatMessageKind};
1500 let mut s = mock_state();
1501 s.session.append(ChatMessage::user("audit"), s.now);
1502 s.session
1503 .append(ChatMessage::assistant("part one of the reply"), s.now);
1504 s.session.append(
1505 kinded(
1506 ChatMessage::system("output limit — continuing"),
1507 ChatMessageKind::RecoveryNudge,
1508 ),
1509 s.now,
1510 );
1511 s.turn = TurnState::Generating {
1512 id: TurnId(3),
1513 started: std::time::SystemTime::now(),
1514 partial_text: "and part two streams in".to_string(),
1515 partial_reasoning: String::new(),
1516 tokens: 0,
1517 phase: GenPhase::Streaming,
1518 provider_continuation: None,
1519 pending_tool_calls: Vec::new(),
1520 continuation: true,
1521 };
1522
1523 let out = render_to_string(&s);
1524 assert!(out.contains("part one of the reply"));
1525 assert!(out.contains("and part two streams in"));
1526 assert!(!out.contains("continuing"), "live nudge hidden too");
1527 assert_eq!(
1528 out.matches('●').count(),
1529 1,
1530 "the streaming half joins the committed bubble:\n{out}"
1531 );
1532 }
1533
1534 #[test]
1535 fn user_prompt_renders_with_highlight_band() {
1536 let mut s = mock_state();
1537 s.session
1538 .append(crate::models::ChatMessage::user("hello there"), s.now);
1539 let buf = render_to_buffer(&s);
1540 let band_bg = crate::render::theme::Theme::dark()
1541 .colors
1542 .user_message_background
1543 .to_color();
1544 let y = (0..buf.area.height)
1546 .find(|&y| {
1547 (0..buf.area.width)
1548 .map(|x| buf[(x, y)].symbol())
1549 .collect::<String>()
1550 .contains("hello there")
1551 })
1552 .expect("user prompt should render");
1553 let banded = (0..buf.area.width)
1556 .filter(|&x| buf[(x, y)].bg == band_bg)
1557 .count();
1558 assert!(
1559 banded >= (buf.area.width as usize) * 3 / 4,
1560 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1561 buf.area.width
1562 );
1563 }
1564
1565 #[test]
1566 fn idle_state_renders_cwd_and_model_footer() {
1567 let s = mock_state();
1568 let frame = render_to_string(&s);
1569 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1571 assert!(frame.contains("ollama/test"));
1572 }
1573
1574 #[test]
1575 fn status_line_appears_during_generating() {
1576 let mut s = mock_state();
1577 s.turn = crate::domain::transition::start_generating(
1578 crate::domain::TurnId(1),
1579 std::time::SystemTime::now(),
1580 );
1581 let frame = render_to_string(&s);
1582 assert!(
1583 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1584 "expected generation status in frame"
1585 );
1586 }
1587
1588 #[test]
1589 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1590 use crate::domain::PendingToolCall;
1591 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1592 let mut s = mock_state();
1593 let call = PendingToolCall {
1594 call_id: crate::domain::ToolCallId(1),
1595 source: ModelToolCall {
1596 id: Some("c1".to_string()),
1597 function: FunctionCall {
1598 name: "execute_command".to_string(),
1599 arguments: serde_json::json!({"command": "npm run dev"}),
1600 },
1601 },
1602 };
1603 s.turn = TurnState::ExecutingTools {
1604 id: crate::domain::TurnId(1),
1605 started: std::time::SystemTime::now(),
1606 calls: vec![call],
1607 outcomes: vec![None],
1608 };
1609 let frame = render_to_string(&s);
1610 assert!(frame.contains("Running tools..."), "got: {frame}");
1613 assert!(
1614 !frame.contains("Running tools:"),
1615 "status line must not carry tool detail; got: {frame}"
1616 );
1617 assert!(
1619 frame.contains("npm run dev"),
1620 "transcript must show the in-flight call's action row; got: {frame}"
1621 );
1622 }
1623
1624 #[test]
1625 fn pending_question_and_agent_calls_get_no_transcript_row() {
1626 use crate::domain::PendingToolCall;
1627 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1628 let mut s = mock_state();
1629 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1630 call_id: crate::domain::ToolCallId(id),
1631 source: ModelToolCall {
1632 id: Some(format!("c{id}")),
1633 function: FunctionCall {
1634 name: name.to_string(),
1635 arguments: args,
1636 },
1637 },
1638 };
1639 s.turn = TurnState::ExecutingTools {
1640 id: crate::domain::TurnId(1),
1641 started: std::time::SystemTime::now(),
1642 calls: vec![
1643 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1644 mk(
1645 2,
1646 "agent",
1647 serde_json::json!({"description": "scan the repo"}),
1648 ),
1649 ],
1650 outcomes: vec![None, None],
1651 };
1652 let frame = render_to_string(&s);
1653 assert!(
1656 !frame.contains("ask_user_question"),
1657 "pending question must not surface as a transcript row or status text; got: {frame}"
1658 );
1659 }
1660
1661 #[test]
1662 fn status_line_appears_during_tool_execution_and_shows_queue() {
1663 let mut s = mock_state();
1664 s.turn = TurnState::ExecutingTools {
1665 id: crate::domain::TurnId(1),
1666 started: std::time::SystemTime::now(),
1667 calls: Vec::new(),
1668 outcomes: Vec::new(),
1669 };
1670 s.ui.queued_messages
1671 .push_back(crate::domain::QueuedMessage {
1672 text: "please steer this".to_string(),
1673 attachment_ids: Vec::new(),
1674 });
1675 let frame = render_to_string(&s);
1676 assert!(frame.contains("Running tools"), "expected tool status");
1677 assert!(
1678 frame.contains("please steer this"),
1679 "queued busy input must be visible"
1680 );
1681 }
1682
1683 #[test]
1684 fn reasoning_blocks_are_collapsed_by_default() {
1685 let mut s = mock_state();
1686 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
1687 first_msg.thinking = Some("first private chain of thought".to_string());
1688 s.session.append(first_msg, s.now);
1689 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
1690 second_msg.thinking = Some("second private chain of thought".to_string());
1691 s.session.append(second_msg, s.now);
1692 let frame = render_to_string(&s);
1693 assert!(!frame.contains("Reasoning hidden"));
1695 assert!(frame.contains("first visible answer"));
1696 assert!(frame.contains("second visible answer"));
1697 assert!(!frame.contains("first private chain of thought"));
1698 assert!(!frame.contains("second private chain of thought"));
1699 }
1700
1701 #[test]
1705 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1706 let mut s = mock_state();
1707 let mut msg = crate::models::ChatMessage::assistant("");
1708 msg.thinking = Some("private chain of thought".to_string());
1709 msg.actions.push(crate::domain::ActionDisplay {
1710 action_type: "Bash".to_string(),
1711 target: "dir".to_string(),
1712 result: crate::domain::ActionResult::Success {
1713 output: "ok".to_string(),
1714 images: None,
1715 },
1716 details: crate::domain::ActionDetails::Simple,
1717 duration_seconds: Some(0.015),
1718 metadata: None,
1719 });
1720 s.session.append(msg, s.now);
1721 let frame = render_to_string(&s);
1722 assert!(
1723 !frame.contains("Reasoning hidden"),
1724 "no reasoning-hidden placeholder"
1725 );
1726 assert!(
1727 frame.contains("Bash"),
1728 "the action still renders even though reasoning is hidden"
1729 );
1730 }
1731
1732 #[test]
1733 fn committed_message_appears_in_chat_pane() {
1734 let mut s = mock_state();
1735 s.session.append(
1736 crate::models::ChatMessage::user("unique-user-token-xyz"),
1737 s.now,
1738 );
1739 let frame = render_to_string(&s);
1740 assert!(frame.contains("unique-user-token-xyz"));
1741 }
1742
1743 #[test]
1744 fn palette_renders_when_input_starts_with_slash() {
1745 let mut s = mock_state();
1746 s.ui.input_buffer = "/help".to_string();
1747 s.ui.input_cursor = 5;
1748 let frame = render_to_string(&s);
1749 assert!(frame.contains("help"));
1751 }
1752
1753 #[test]
1754 fn status_line_helper_maps_idle_to_idle() {
1755 assert_eq!(
1756 GenerationStatus::from_turn(&TurnState::Idle),
1757 GenerationStatus::Idle
1758 );
1759 }
1760}