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(test)]
1135mod snapshots;
1136
1137#[cfg(test)]
1139mod bench;
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144 use crate::app::Config;
1145 use crate::domain::{State, TurnState};
1146 use ratatui::Terminal;
1147 use ratatui::backend::TestBackend;
1148 use std::path::PathBuf;
1149
1150 fn mock_state() -> State {
1151 State::new(
1152 Config::default(),
1153 PathBuf::from("/tmp/p"),
1154 "ollama/test".to_string(),
1155 chrono::Local::now(),
1156 )
1157 }
1158
1159 fn render_to_string(state: &State) -> String {
1160 render_frame(state, &mut RenderCache::new(), 80, 24)
1161 }
1162
1163 fn render_to_buffer(state: &State) -> ratatui::buffer::Buffer {
1164 let backend = TestBackend::new(80, 24);
1165 let mut terminal = Terminal::new(backend).expect("terminal");
1166 let mut rstate = RenderCache::new();
1167 terminal
1168 .draw(|f| render(state, &mut rstate, f))
1169 .expect("draw");
1170 terminal.backend().buffer().clone()
1171 }
1172
1173 #[test]
1174 fn theme_choice_changes_colors_never_glyphs() {
1175 let mut state = mock_state();
1179 state
1180 .session
1181 .append(crate::models::ChatMessage::user("hello"), state.now);
1182 let dark = render_to_string(&state);
1183 state.ui.theme = crate::app::ThemeChoice::Light;
1184 let light = render_to_string(&state);
1185 assert_eq!(dark, light, "light theme changed glyphs");
1186 state.ui.no_color = true;
1187 let plain = render_to_string(&state);
1188 assert_eq!(dark, plain, "NO_COLOR changed glyphs");
1189 }
1190
1191 #[test]
1192 fn theme_memo_swaps_palette_on_state_change() {
1193 let mut state = mock_state();
1194 let mut rstate = RenderCache::new();
1195 render_frame(&state, &mut rstate, 80, 24);
1196 assert_eq!(rstate.theme.name, "Dark");
1197 state.ui.theme = crate::app::ThemeChoice::Light;
1198 render_frame(&state, &mut rstate, 80, 24);
1199 assert_eq!(rstate.theme.name, "Light");
1200 state.ui.no_color = true;
1202 render_frame(&state, &mut rstate, 80, 24);
1203 assert_eq!(rstate.theme.name, "Plain");
1204 }
1205
1206 #[test]
1207 fn agent_calls_get_panel_rows_and_a_calm_status_override() {
1208 use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1209
1210 let mut state = mock_state();
1211 let call_id = ToolCallId(7);
1212 state.turn = TurnState::ExecutingTools {
1213 id: TurnId(1),
1214 started: std::time::SystemTime::now(),
1215 calls: vec![PendingToolCall {
1216 call_id,
1217 source: crate::models::tool_call::ToolCall {
1218 id: None,
1219 function: crate::models::tool_call::FunctionCall {
1220 name: "agent".to_string(),
1221 arguments: serde_json::json!({"description": "explore crates"}),
1222 },
1223 },
1224 }],
1225 outcomes: vec![None],
1226 };
1227 state.ui.live_tool_status.insert(
1228 call_id,
1229 LiveToolStatus {
1230 activity: "read_file…".to_string(),
1231 tokens: 12_300,
1232 },
1233 );
1234
1235 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1238 assert!(
1239 live.is_empty(),
1240 "a pending agent call must not synthesize a transcript row"
1241 );
1242 let (rows, override_text, bg_available) = agent_panel_data(&state);
1243 assert_eq!(override_text.as_deref(), Some("Running 1 agent"));
1244 assert!(bg_available, "agents are detachable via ctrl+b");
1245 assert_eq!(rows.len(), 1);
1246 assert_eq!(rows[0].description, "explore crates");
1247 assert_eq!(rows[0].activity, "read_file…");
1248 assert_eq!(rows[0].tokens, 12_300);
1249 assert!(!rows[0].backgrounded);
1250 }
1251
1252 #[test]
1253 fn mixed_turn_names_first_non_agent_tool_with_stable_activity() {
1254 use crate::domain::{LiveToolStatus, PendingToolCall, ToolCallId, TurnId};
1255
1256 let mut state = mock_state();
1257 let exec_id = ToolCallId(8);
1258 let agent_id = ToolCallId(9);
1259 let call = |id, name: &str, args| PendingToolCall {
1260 call_id: id,
1261 source: crate::models::tool_call::ToolCall {
1262 id: None,
1263 function: crate::models::tool_call::FunctionCall {
1264 name: name.to_string(),
1265 arguments: args,
1266 },
1267 },
1268 };
1269 state.turn = TurnState::ExecutingTools {
1270 id: TurnId(1),
1271 started: std::time::SystemTime::now(),
1272 calls: vec![
1273 call(
1274 exec_id,
1275 "execute_command",
1276 serde_json::json!({"command": "cargo test"}),
1277 ),
1278 call(
1279 agent_id,
1280 "agent",
1281 serde_json::json!({"description": "audit docs"}),
1282 ),
1283 ],
1284 outcomes: vec![None, None],
1285 };
1286 state.ui.live_tool_status.insert(
1287 exec_id,
1288 LiveToolStatus {
1289 activity: String::new(),
1290 tokens: 0,
1291 },
1292 );
1293
1294 let live = build_live_messages(&[], &state.turn, chrono::Local::now());
1297 assert_eq!(live.len(), 1, "one synthetic message carries the rows");
1298 let actions = &live[0].actions;
1299 assert_eq!(actions.len(), 1, "the agent call gets no transcript row");
1300 assert_eq!(actions[0].action_type, "Bash");
1301 assert_eq!(actions[0].target, "cargo test");
1302 assert!(matches!(
1303 actions[0].result,
1304 crate::domain::ActionResult::Running
1305 ));
1306 let (rows, override_text, _) = agent_panel_data(&state);
1307 assert_eq!(override_text, None);
1308 assert_eq!(rows.len(), 1);
1309 }
1310
1311 #[test]
1312 fn build_live_messages_borrows_idle_and_stamps_partial_with_injected_now() {
1313 use crate::domain::{GenPhase, TurnId};
1314 use crate::models::ChatMessage;
1315 use std::borrow::Cow;
1316 use std::time::SystemTime;
1317
1318 let committed = vec![ChatMessage::user("hi")];
1319 let now = chrono::Local::now();
1320
1321 let idle = build_live_messages(&committed, &TurnState::Idle, now);
1323 assert!(matches!(idle, Cow::Borrowed(_)));
1324 assert_eq!(idle.len(), 1);
1325
1326 let turn = TurnState::Generating {
1329 id: TurnId(1),
1330 started: SystemTime::now(),
1331 partial_text: "draft".to_string(),
1332 partial_reasoning: String::new(),
1333 tokens: 0,
1334 phase: GenPhase::Sending,
1335 provider_continuation: None,
1336 pending_tool_calls: Vec::new(),
1337 continuation: false,
1338 };
1339 let live = build_live_messages(&committed, &turn, now);
1340 assert!(matches!(live, Cow::Owned(_)));
1341 assert_eq!(live.len(), 2);
1342 assert_eq!(live[1].timestamp, now);
1343 }
1344
1345 fn kinded(
1346 mut msg: crate::models::ChatMessage,
1347 kind: crate::models::ChatMessageKind,
1348 ) -> crate::models::ChatMessage {
1349 msg.kind = kind;
1350 msg
1351 }
1352
1353 #[test]
1354 fn stitch_committed_merges_chain_and_hides_nudges() {
1355 use crate::models::{ChatMessage, ChatMessageKind};
1356 let mut part1 = ChatMessage::assistant("The audit found three issues in the resolver");
1357 part1.thinking = Some("first trace".to_string());
1358 let mut part2 = kinded(
1360 ChatMessage::assistant("issues in the resolver, and here is the fix."),
1361 ChatMessageKind::Continuation,
1362 );
1363 part2.thinking = Some("second trace".to_string());
1364 let committed = vec![
1365 ChatMessage::user("audit the widget"),
1366 part1,
1367 kinded(
1368 ChatMessage::system("resume nudge"),
1369 ChatMessageKind::RecoveryNudge,
1370 ),
1371 part2,
1372 ];
1373
1374 assert!(needs_stitch(&committed, &TurnState::Idle));
1375 let stitched = stitch_committed(&committed);
1376 assert_eq!(stitched.len(), 2, "user + one merged bubble");
1377 assert_eq!(
1378 stitched[1].content,
1379 "The audit found three issues in the resolver, and here is the fix.",
1380 "contents merge with the resume echo trimmed"
1381 );
1382 assert_eq!(
1383 stitched[1].thinking.as_deref(),
1384 Some("first trace\n\nsecond trace"),
1385 "both reasoning segments survive in order"
1386 );
1387 assert!(
1388 !stitched.iter().any(|m| m.content.contains("resume nudge")),
1389 "nudges never render"
1390 );
1391 }
1392
1393 #[test]
1396 fn context_markers_are_hidden_from_the_transcript() {
1397 use crate::models::{ChatMessage, ChatMessageKind};
1398 let committed = vec![
1399 ChatMessage::user("plan this"),
1400 kinded(
1401 ChatMessage::system("Plan mode is now ON. Author the plan at x.md."),
1402 ChatMessageKind::ContextMarker,
1403 ),
1404 ChatMessage::assistant("Grounding first."),
1405 ];
1406 assert!(
1411 !needs_stitch(&committed, &TurnState::Idle),
1412 "a marker alone must not defeat the zero-copy path",
1413 );
1414 let stitched = stitch_committed(&committed);
1416 assert_eq!(stitched.len(), 2, "user + assistant only");
1417 assert!(
1418 !stitched
1419 .iter()
1420 .any(|m| m.content.contains("Plan mode is now ON")),
1421 "markers never render"
1422 );
1423 }
1424
1425 #[test]
1426 fn stitch_refuses_non_bubble_predecessor() {
1427 use crate::models::{ChatMessage, ChatMessageKind};
1428 let committed = vec![
1432 kinded(
1433 ChatMessage::assistant("checkpoint summary"),
1434 ChatMessageKind::ContextCheckpoint,
1435 ),
1436 kinded(
1437 ChatMessage::assistant("orphaned continuation"),
1438 ChatMessageKind::Continuation,
1439 ),
1440 ];
1441 let stitched = stitch_committed(&committed);
1442 assert_eq!(stitched.len(), 2, "no merge into a checkpoint");
1443 assert_eq!(stitched[1].content, "orphaned continuation");
1444 }
1445
1446 #[test]
1447 fn needs_stitch_is_false_for_plain_sessions() {
1448 use crate::models::ChatMessage;
1449 let committed = vec![
1452 ChatMessage::user("hi"),
1453 ChatMessage::assistant("hello"),
1454 ChatMessage::system("note"),
1455 ];
1456 assert!(!needs_stitch(&committed, &TurnState::Idle));
1457 }
1458
1459 #[test]
1467 fn a_live_continuation_still_forces_the_stitch() {
1468 use crate::models::{ChatMessage, ChatMessageKind};
1469 let committed = vec![
1470 ChatMessage::user("write it"),
1471 ChatMessage::assistant("first half"),
1472 kinded(
1473 ChatMessage::system("output limit — continuing"),
1474 ChatMessageKind::RecoveryNudge,
1475 ),
1476 ];
1477 let streaming = TurnState::Generating {
1478 id: crate::domain::TurnId(1),
1479 started: std::time::SystemTime::UNIX_EPOCH,
1480 partial_text: "first half and the rest".to_string(),
1481 partial_reasoning: String::new(),
1482 tokens: 0,
1483 phase: crate::domain::GenPhase::Streaming,
1484 provider_continuation: None,
1485 pending_tool_calls: Vec::new(),
1486 continuation: true,
1487 };
1488 assert!(
1489 needs_stitch(&committed, &streaming),
1490 "a live continuation needs the nudge stripped to find its bubble",
1491 );
1492 let stitched = stitch_committed(&committed);
1494 assert!(
1495 stitched.last().is_some_and(mergeable_into),
1496 "the stitched tail is the assistant bubble the partial merges into",
1497 );
1498 }
1499
1500 #[test]
1501 fn build_live_messages_stamps_streaming_continuation_and_trims_echo() {
1502 use crate::domain::{GenPhase, TurnId};
1503 use crate::models::{ChatMessage, ChatMessageKind};
1504
1505 let committed = vec![ChatMessage::assistant(
1506 "the fix lands in the resolver module",
1507 )];
1508 let turn = TurnState::Generating {
1509 id: TurnId(2),
1510 started: std::time::SystemTime::now(),
1511 partial_text: "in the resolver module, specifically the clamp".to_string(),
1512 partial_reasoning: String::new(),
1513 tokens: 0,
1514 phase: GenPhase::Streaming,
1515 provider_continuation: None,
1516 pending_tool_calls: Vec::new(),
1517 continuation: true,
1518 };
1519 let live = build_live_messages(&committed, &turn, chrono::Local::now());
1520 let streamed = live.last().expect("pseudo-message appended");
1521 assert_eq!(
1522 streamed.kind,
1523 ChatMessageKind::Continuation,
1524 "the live half is stamped so the widget draws it prefix-less"
1525 );
1526 assert_eq!(
1527 streamed.content, ", specifically the clamp",
1528 "the leading resume echo is trimmed against the committed tail"
1529 );
1530 }
1531
1532 #[test]
1533 fn auto_continued_reply_renders_as_one_bubble() {
1534 use crate::models::{ChatMessage, ChatMessageKind};
1535 let mut s = mock_state();
1536 s.session.append(ChatMessage::user("audit"), s.now);
1537 s.session
1538 .append(ChatMessage::assistant("part one of the reply"), s.now);
1539 s.session.append(
1540 kinded(
1541 ChatMessage::system("output limit — continuing"),
1542 ChatMessageKind::RecoveryNudge,
1543 ),
1544 s.now,
1545 );
1546 s.session.append(
1547 kinded(
1548 ChatMessage::assistant("and part two lands here"),
1549 ChatMessageKind::Continuation,
1550 ),
1551 s.now,
1552 );
1553
1554 let out = render_to_string(&s);
1555 assert!(out.contains("part one of the reply"));
1556 assert!(out.contains("and part two lands here"));
1557 assert!(
1558 !out.contains("continuing"),
1559 "the recovery nudge never renders"
1560 );
1561 assert_eq!(
1562 out.matches('●').count(),
1563 1,
1564 "both halves share one assistant bullet:\n{out}"
1565 );
1566 }
1567
1568 #[test]
1569 fn streaming_continuation_renders_without_fresh_bullet() {
1570 use crate::domain::{GenPhase, TurnId};
1571 use crate::models::{ChatMessage, ChatMessageKind};
1572 let mut s = mock_state();
1573 s.session.append(ChatMessage::user("audit"), s.now);
1574 s.session
1575 .append(ChatMessage::assistant("part one of the reply"), s.now);
1576 s.session.append(
1577 kinded(
1578 ChatMessage::system("output limit — continuing"),
1579 ChatMessageKind::RecoveryNudge,
1580 ),
1581 s.now,
1582 );
1583 s.turn = TurnState::Generating {
1584 id: TurnId(3),
1585 started: std::time::SystemTime::now(),
1586 partial_text: "and part two streams in".to_string(),
1587 partial_reasoning: String::new(),
1588 tokens: 0,
1589 phase: GenPhase::Streaming,
1590 provider_continuation: None,
1591 pending_tool_calls: Vec::new(),
1592 continuation: true,
1593 };
1594
1595 let out = render_to_string(&s);
1596 assert!(out.contains("part one of the reply"));
1597 assert!(out.contains("and part two streams in"));
1598 assert!(!out.contains("continuing"), "live nudge hidden too");
1599 assert_eq!(
1600 out.matches('●').count(),
1601 1,
1602 "the streaming half joins the committed bubble:\n{out}"
1603 );
1604 }
1605
1606 #[test]
1607 fn user_prompt_renders_with_highlight_band() {
1608 let mut s = mock_state();
1609 s.session
1610 .append(crate::models::ChatMessage::user("hello there"), s.now);
1611 let buf = render_to_buffer(&s);
1612 let band_bg = crate::render::theme::Theme::dark()
1613 .colors
1614 .user_message_background
1615 .to_color();
1616 let y = (0..buf.area.height)
1618 .find(|&y| {
1619 (0..buf.area.width)
1620 .map(|x| buf[(x, y)].symbol())
1621 .collect::<String>()
1622 .contains("hello there")
1623 })
1624 .expect("user prompt should render");
1625 let banded = (0..buf.area.width)
1628 .filter(|&x| buf[(x, y)].bg == band_bg)
1629 .count();
1630 assert!(
1631 banded >= (buf.area.width as usize) * 3 / 4,
1632 "user prompt band should fill most of the row; only {banded}/{} cells banded",
1633 buf.area.width
1634 );
1635 }
1636
1637 #[test]
1638 fn idle_state_renders_cwd_and_model_footer() {
1639 let s = mock_state();
1640 let frame = render_to_string(&s);
1641 assert!(frame.contains("/tmp/p") || frame.contains("tmp"));
1643 assert!(frame.contains("ollama/test"));
1644 }
1645
1646 #[test]
1647 fn status_line_appears_during_generating() {
1648 let mut s = mock_state();
1649 s.turn = crate::domain::transition::start_generating(
1650 crate::domain::TurnId(1),
1651 std::time::SystemTime::now(),
1652 );
1653 let frame = render_to_string(&s);
1654 assert!(
1655 frame.contains("Sending") || frame.contains("Thinking") || frame.contains("Streaming"),
1656 "expected generation status in frame"
1657 );
1658 }
1659
1660 #[test]
1661 fn in_flight_tool_renders_as_transcript_row_with_bare_status_line() {
1662 use crate::domain::PendingToolCall;
1663 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1664 let mut s = mock_state();
1665 let call = PendingToolCall {
1666 call_id: crate::domain::ToolCallId(1),
1667 source: ModelToolCall {
1668 id: Some("c1".to_string()),
1669 function: FunctionCall {
1670 name: "execute_command".to_string(),
1671 arguments: serde_json::json!({"command": "npm run dev"}),
1672 },
1673 },
1674 };
1675 s.turn = TurnState::ExecutingTools {
1676 id: crate::domain::TurnId(1),
1677 started: std::time::SystemTime::now(),
1678 calls: vec![call],
1679 outcomes: vec![None],
1680 };
1681 let frame = render_to_string(&s);
1682 assert!(frame.contains("Running tools..."), "got: {frame}");
1685 assert!(
1686 !frame.contains("Running tools:"),
1687 "status line must not carry tool detail; got: {frame}"
1688 );
1689 assert!(
1691 frame.contains("npm run dev"),
1692 "transcript must show the in-flight call's action row; got: {frame}"
1693 );
1694 }
1695
1696 #[test]
1697 fn pending_question_and_agent_calls_get_no_transcript_row() {
1698 use crate::domain::PendingToolCall;
1699 use crate::models::tool_call::{FunctionCall, ToolCall as ModelToolCall};
1700 let mut s = mock_state();
1701 let mk = |id: u64, name: &str, args: serde_json::Value| PendingToolCall {
1702 call_id: crate::domain::ToolCallId(id),
1703 source: ModelToolCall {
1704 id: Some(format!("c{id}")),
1705 function: FunctionCall {
1706 name: name.to_string(),
1707 arguments: args,
1708 },
1709 },
1710 };
1711 s.turn = TurnState::ExecutingTools {
1712 id: crate::domain::TurnId(1),
1713 started: std::time::SystemTime::now(),
1714 calls: vec![
1715 mk(1, "ask_user_question", serde_json::json!({"questions": []})),
1716 mk(
1717 2,
1718 "agent",
1719 serde_json::json!({"description": "scan the repo"}),
1720 ),
1721 ],
1722 outcomes: vec![None, None],
1723 };
1724 let frame = render_to_string(&s);
1725 assert!(
1728 !frame.contains("ask_user_question"),
1729 "pending question must not surface as a transcript row or status text; got: {frame}"
1730 );
1731 }
1732
1733 #[test]
1734 fn status_line_appears_during_tool_execution_and_shows_queue() {
1735 let mut s = mock_state();
1736 s.turn = TurnState::ExecutingTools {
1737 id: crate::domain::TurnId(1),
1738 started: std::time::SystemTime::now(),
1739 calls: Vec::new(),
1740 outcomes: Vec::new(),
1741 };
1742 s.ui.queued_messages
1743 .push_back(crate::domain::QueuedMessage {
1744 text: "please steer this".to_string(),
1745 attachment_ids: Vec::new(),
1746 });
1747 let frame = render_to_string(&s);
1748 assert!(frame.contains("Running tools"), "expected tool status");
1749 assert!(
1750 frame.contains("please steer this"),
1751 "queued busy input must be visible"
1752 );
1753 }
1754
1755 #[test]
1756 fn reasoning_blocks_are_collapsed_by_default() {
1757 let mut s = mock_state();
1758 let mut first_msg = crate::models::ChatMessage::assistant("first visible answer");
1759 first_msg.thinking = Some("first private chain of thought".to_string());
1760 s.session.append(first_msg, s.now);
1761 let mut second_msg = crate::models::ChatMessage::assistant("second visible answer");
1762 second_msg.thinking = Some("second private chain of thought".to_string());
1763 s.session.append(second_msg, s.now);
1764 let frame = render_to_string(&s);
1765 assert!(!frame.contains("Reasoning hidden"));
1767 assert!(frame.contains("first visible answer"));
1768 assert!(frame.contains("second visible answer"));
1769 assert!(!frame.contains("first private chain of thought"));
1770 assert!(!frame.contains("second private chain of thought"));
1771 }
1772
1773 #[test]
1777 fn hidden_reasoning_then_action_renders_action_without_placeholder() {
1778 let mut s = mock_state();
1779 let mut msg = crate::models::ChatMessage::assistant("");
1780 msg.thinking = Some("private chain of thought".to_string());
1781 msg.actions.push(crate::domain::ActionDisplay {
1782 action_type: "Bash".to_string(),
1783 target: "dir".to_string(),
1784 result: crate::domain::ActionResult::Success {
1785 output: "ok".to_string(),
1786 images: None,
1787 },
1788 details: crate::domain::ActionDetails::Simple,
1789 duration_seconds: Some(0.015),
1790 metadata: None,
1791 });
1792 s.session.append(msg, s.now);
1793 let frame = render_to_string(&s);
1794 assert!(
1795 !frame.contains("Reasoning hidden"),
1796 "no reasoning-hidden placeholder"
1797 );
1798 assert!(
1799 frame.contains("Bash"),
1800 "the action still renders even though reasoning is hidden"
1801 );
1802 }
1803
1804 #[test]
1805 fn committed_message_appears_in_chat_pane() {
1806 let mut s = mock_state();
1807 s.session.append(
1808 crate::models::ChatMessage::user("unique-user-token-xyz"),
1809 s.now,
1810 );
1811 let frame = render_to_string(&s);
1812 assert!(frame.contains("unique-user-token-xyz"));
1813 }
1814
1815 #[test]
1816 fn palette_renders_when_input_starts_with_slash() {
1817 let mut s = mock_state();
1818 s.ui.input_buffer = "/help".to_string();
1819 s.ui.input_cursor = 5;
1820 let frame = render_to_string(&s);
1821 assert!(frame.contains("help"));
1823 }
1824
1825 #[test]
1826 fn status_line_helper_maps_idle_to_idle() {
1827 assert_eq!(
1828 GenerationStatus::from_turn(&TurnState::Idle),
1829 GenerationStatus::Idle
1830 );
1831 }
1832}