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