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